@oneuptime/common 12.0.8 → 12.0.9
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/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +1 -1
- package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +1 -1
- package/Models/DatabaseModels/AIConversation.ts +32 -0
- package/Models/DatabaseModels/AIConversationMessage.ts +41 -0
- package/Models/DatabaseModels/AlertEpisodeMember.ts +27 -0
- package/Models/DatabaseModels/IncidentEpisodeMember.ts +28 -0
- package/Models/DatabaseModels/Index.ts +12 -4
- package/Models/DatabaseModels/{TelemetryEntity.ts → InventoryItem.ts} +139 -9
- package/Models/DatabaseModels/InventoryItemCustomField.ts +434 -0
- package/Models/DatabaseModels/{TelemetryEntityRelationship.ts → InventoryItemRelationship.ts} +8 -8
- package/Models/DatabaseModels/NetworkDevice.ts +141 -0
- package/Models/DatabaseModels/NetworkDeviceLink.ts +699 -0
- package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +467 -0
- package/Models/DatabaseModels/NetworkTopologySuppression.ts +429 -0
- package/Models/DatabaseModels/OnCallDutyPolicyFeed.ts +9 -0
- package/Models/DatabaseModels/Project.ts +78 -0
- package/Models/DatabaseModels/UserCall.ts +42 -0
- package/Models/DatabaseModels/UserEmail.ts +44 -0
- package/Models/DatabaseModels/UserNotificationRule.ts +425 -55
- package/Models/DatabaseModels/UserOnCallLogTimeline.ts +24 -2
- package/Models/DatabaseModels/UserPush.ts +49 -0
- package/Models/DatabaseModels/UserSMS.ts +43 -0
- package/Models/DatabaseModels/UserTelegram.ts +59 -0
- package/Models/DatabaseModels/UserWebhook.ts +52 -0
- package/Models/DatabaseModels/UserWhatsApp.ts +41 -0
- package/Models/DatabaseModels/WorkflowVariable.ts +12 -0
- package/Server/API/AIChatAPI.ts +315 -1
- package/Server/API/DashboardAPI.ts +217 -1
- package/Server/API/OnCallReadinessAPI.ts +841 -0
- package/Server/API/TeamComplianceAPI.ts +69 -17
- package/Server/API/TelemetryAPI.ts +220 -12
- package/Server/EnvironmentConfig.ts +52 -0
- package/Server/Infrastructure/Postgres/DataSourceOptions.ts +22 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +4 -4
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +5 -5
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.ts +35 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.ts +82 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.ts +91 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.ts +47 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.ts +255 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.ts +107 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.ts +90 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.ts +39 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.ts +33 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.ts +59 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +20 -0
- package/Server/Infrastructure/Queue.ts +78 -13
- package/Server/Middleware/PublicDashboardRateLimit.ts +593 -0
- package/Server/Services/AIService.ts +7 -0
- package/Server/Services/AlertEpisodeStateTimelineService.ts +29 -0
- package/Server/Services/AlertSeverityService.ts +63 -0
- package/Server/Services/DashboardService.ts +9 -10
- package/Server/Services/DatabaseService.ts +32 -2
- package/Server/Services/IncidentEpisodeStateTimelineService.ts +29 -0
- package/Server/Services/IncidentSeverityService.ts +76 -0
- package/Server/Services/Index.ts +12 -4
- package/Server/Services/InventoryItemCustomFieldService.ts +9 -0
- package/Server/Services/{TelemetryEntityRelationshipService.ts → InventoryItemRelationshipService.ts} +4 -4
- package/Server/Services/{TelemetryEntityService.ts → InventoryItemService.ts} +89 -20
- package/Server/Services/LogAggregationService.ts +45 -8
- package/Server/Services/MetricAggregationService.ts +121 -0
- package/Server/Services/MetricService.ts +7 -7
- package/Server/Services/NetworkDeviceLinkRuleService.ts +10 -0
- package/Server/Services/NetworkDeviceLinkService.ts +84 -0
- package/Server/Services/NetworkDeviceService.ts +140 -0
- package/Server/Services/NetworkSiteService.ts +77 -25
- package/Server/Services/NetworkTopologySuppressionService.ts +84 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +41 -29
- package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +8 -0
- package/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.ts +62 -13
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +61 -1
- package/Server/Services/OnCallNotificationAlertingService.ts +742 -0
- package/Server/Services/OnCallReadinessService.ts +2803 -0
- package/Server/Services/OnCallSetupReminderService.ts +955 -0
- package/Server/Services/ProfileAggregationService.ts +123 -0
- package/Server/Services/StatusPageService.ts +9 -10
- package/Server/Services/TeamComplianceService.ts +429 -252
- package/Server/Services/UserCallService.ts +26 -1
- package/Server/Services/UserEmailService.ts +26 -1
- package/Server/Services/UserNotificationRuleAdminService.ts +1183 -0
- package/Server/Services/UserNotificationRuleService.ts +3812 -333
- package/Server/Services/UserOnCallLogService.ts +561 -48
- package/Server/Services/UserPushService.ts +29 -0
- package/Server/Services/UserService.ts +11 -0
- package/Server/Services/UserSmsService.ts +26 -1
- package/Server/Services/UserTelegramService.ts +24 -1
- package/Server/Services/UserWebhookService.ts +28 -1
- package/Server/Services/UserWhatsAppService.ts +24 -1
- package/Server/Types/Database/Permissions/BasePermission.ts +19 -0
- package/Server/Types/Database/Permissions/CreatePermission.ts +164 -0
- package/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.ts +340 -0
- package/Server/Types/Database/Permissions/QueryPermission.ts +48 -0
- package/Server/Types/Database/Permissions/TenantPermission.ts +8 -1
- package/Server/Types/Workflow/Components/API/Delete.ts +1 -1
- package/Server/Types/Workflow/Components/API/Get.ts +1 -1
- package/Server/Types/Workflow/Components/API/Patch.ts +1 -1
- package/Server/Types/Workflow/Components/API/Post.ts +1 -1
- package/Server/Types/Workflow/Components/API/Put.ts +1 -1
- package/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.ts +29 -5
- package/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.ts +18 -10
- package/Server/Types/Workflow/Components/BaseModel/ModelArguments.ts +55 -0
- package/Server/Types/Workflow/Components/Conditions/IfElse.ts +3 -17
- package/Server/Types/Workflow/Components/Email.ts +25 -7
- package/Server/Types/Workflow/Components/JavaScript.ts +10 -3
- package/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.ts +1 -1
- package/Server/Utils/AI/Chat/ChatAgentRunner.ts +643 -48
- package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +32 -3
- package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +20 -6
- package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +7 -0
- package/Server/Utils/AI/Toolbox/AIActionTools.ts +2 -2
- package/Server/Utils/AI/Toolbox/AIMetaTools.ts +863 -0
- package/Server/Utils/AI/Toolbox/AlertTools.ts +177 -15
- package/Server/Utils/AI/Toolbox/IncidentTools.ts +191 -10
- package/Server/Utils/AI/Toolbox/Index.ts +48 -0
- package/Server/Utils/AI/Toolbox/MonitorTools.ts +298 -11
- package/Server/Utils/AI/Toolbox/NoteWriteTools.ts +295 -0
- package/Server/Utils/AI/Toolbox/OnCallTools.ts +1246 -0
- package/Server/Utils/AI/Toolbox/RunbookTools.ts +424 -0
- package/Server/Utils/AI/Toolbox/SloTools.ts +456 -0
- package/Server/Utils/AI/Toolbox/StatusPageTools.ts +559 -0
- package/Server/Utils/AI/Toolbox/TeamTools.ts +327 -0
- package/Server/Utils/AI/Toolbox/TimelineTools.ts +615 -0
- package/Server/Utils/AI/Toolbox/WorkflowProbeTools.ts +664 -0
- package/Server/Utils/ClientIp.ts +221 -0
- package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +47 -0
- package/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.ts +163 -0
- package/Server/Utils/Dashboard/PublicDashboardSloWidget.ts +147 -0
- package/Server/Utils/Express.ts +12 -17
- package/Server/Utils/LLM/LLMService.ts +85 -8
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +204 -10
- package/Server/Utils/SSRFProtection.ts +98 -23
- package/Server/Utils/StartServer.ts +12 -3
- package/Server/Utils/Telemetry/EntityRegistry.ts +205 -18
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +25 -25
- package/Server/Utils/Telemetry/TelemetryEntity.ts +160 -52
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +7 -3
- package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +2146 -0
- package/Tests/App/Dashboard/CreateWorkflowModal.test.tsx +561 -0
- package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +2470 -0
- package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +1897 -0
- package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3606 -0
- package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +784 -0
- package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +1119 -0
- package/Tests/App/Dashboard/SloWidgetFetching.test.tsx +531 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +1312 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistPage.test.tsx +1390 -0
- package/Tests/Models/InventoryItemModel.test.ts +174 -0
- package/Tests/Models/InventoryItemNaming.test.ts +302 -0
- package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +437 -0
- package/Tests/Server/API/DashboardPublicRateLimit.test.ts +659 -0
- package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +18 -0
- package/Tests/Server/API/DashboardPublicSloAPI.test.ts +880 -0
- package/Tests/Server/API/Helpers.ts +24 -15
- package/Tests/Server/API/OnCallReadinessAPI.test.ts +2680 -0
- package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +915 -0
- package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +533 -0
- package/Tests/Server/Infrastructure/Postgres/InventoryItemArchiveMigration.test.ts +213 -0
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +432 -0
- package/Tests/Server/Infrastructure/Queue.test.ts +293 -0
- package/Tests/Server/Middleware/PublicDashboardRateLimit.test.ts +1645 -0
- package/Tests/Server/Services/AdminRuleEditGuards.test.ts +2848 -0
- package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1393 -0
- package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +1802 -0
- package/Tests/Server/Services/EpisodeStateTimelineNote.test.ts +304 -0
- package/Tests/Server/Services/InventoryItemDisplayName.test.ts +339 -0
- package/Tests/Server/Services/{TelemetryEntityManualCreate.test.ts → InventoryItemManualCreate.test.ts} +27 -27
- package/Tests/Server/Services/IpAllowlistSpoofing.test.ts +450 -0
- package/Tests/Server/Services/LogAggregationService.test.ts +235 -1
- package/Tests/Server/Services/MetricAggregationService.test.ts +231 -0
- package/Tests/Server/Services/MetricEntityMVKeyParity.test.ts +80 -29
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +30 -30
- package/Tests/Server/Services/NetworkSiteService.test.ts +18 -3
- package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +1728 -0
- package/Tests/Server/Services/NotificationDeletionImpact.test.ts +2402 -0
- package/Tests/Server/Services/OnCallDutyPolicyExecutionLogTimelineGapFeed.test.ts +394 -0
- package/Tests/Server/Services/OnCallNotificationFallback.test.ts +1744 -0
- package/Tests/Server/Services/OnCallReadinessService.test.ts +4295 -0
- package/Tests/Server/Services/OnCallSetupReminder.test.ts +1272 -0
- package/Tests/Server/Services/OnCallWeeklyReadinessDigest.test.ts +1021 -0
- package/Tests/Server/Services/ProfileAggregationService.test.ts +296 -0
- package/Tests/Server/Services/SeverityCreationRuleBackfill.test.ts +1536 -0
- package/Tests/Server/Services/SeverityRuleBackfill.test.ts +1818 -0
- package/Tests/Server/Services/TeamComplianceServiceBehaviour.test.ts +1845 -0
- package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +1394 -0
- package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +1166 -0
- package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1468 -0
- package/Tests/Server/Services/UserOnCallLogNoNotificationRules.test.ts +1457 -0
- package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +1546 -0
- package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +529 -0
- package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +1219 -0
- package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +1089 -0
- package/Tests/Server/Types/Workflow/Components/ApiComponentErrorPort.test.ts +2 -1
- package/Tests/Server/Types/Workflow/Components/BaseModelDatabaseComponents.test.ts +190 -0
- package/Tests/Server/Types/Workflow/Components/ChatWebhookComponents.test.ts +44 -14
- package/Tests/Server/Types/Workflow/Components/Email.test.ts +151 -0
- package/Tests/Server/Types/Workflow/Components/IfElse.test.ts +98 -0
- package/Tests/Server/Types/Workflow/Components/JavaScript.test.ts +51 -0
- package/Tests/Server/Utils/AI/AIMetaTools.test.ts +586 -0
- package/Tests/Server/Utils/AI/AlertMonitorFilters.test.ts +582 -0
- package/Tests/Server/Utils/AI/ChatAgentRunner.test.ts +726 -0
- package/Tests/Server/Utils/AI/IncidentToolsFilters.test.ts +315 -0
- package/Tests/Server/Utils/AI/LLMServiceStopReason.test.ts +314 -0
- package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +26 -3
- package/Tests/Server/Utils/AI/NoteWriteTools.test.ts +268 -0
- package/Tests/Server/Utils/AI/ObservabilityChatPrompt.test.ts +169 -0
- package/Tests/Server/Utils/AI/OnCallTools.test.ts +664 -0
- package/Tests/Server/Utils/AI/RunbookTools.test.ts +325 -0
- package/Tests/Server/Utils/AI/SloTools.test.ts +306 -0
- package/Tests/Server/Utils/AI/StatusPageTools.test.ts +391 -0
- package/Tests/Server/Utils/AI/TeamTools.test.ts +257 -0
- package/Tests/Server/Utils/AI/TimelineTools.test.ts +472 -0
- package/Tests/Server/Utils/AI/WorkflowProbeTools.test.ts +428 -0
- package/Tests/Server/Utils/ClientIp.test.ts +438 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +171 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.test.ts +383 -0
- package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +23 -25
- package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +6 -0
- package/Tests/Server/Utils/Monitor/Criteria/DnssecMonitorCriteria.test.ts +307 -0
- package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +468 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorTelemetryDeepLinks.test.ts +460 -0
- package/Tests/Server/Utils/ResponseRateLimitStatusCodes.test.ts +137 -0
- package/Tests/Server/Utils/SSRFProtectionBypasses.test.ts +40 -8
- package/Tests/Server/Utils/SSRFProtectionUserInfo.test.ts +351 -0
- package/Tests/Server/Utils/Telemetry/EntityRegistryRetirement.test.ts +531 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +15 -15
- package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +356 -39
- package/Tests/Types/IP/IP.test.ts +263 -0
- package/Tests/Types/IP/IPWhitelist.test.ts +197 -0
- package/Tests/Types/IP/IPv6.test.ts +12 -1
- package/Tests/Types/Monitor/SnmpOid.test.ts +64 -0
- package/Tests/Types/NetworkDevice/NetworkDeviceMonitoringMethod.test.ts +110 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +106 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeDifferential.test.ts +511 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageEndToEnd.test.ts +489 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageGapTolerance.test.ts +479 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageState.test.ts +822 -0
- package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +6 -6
- package/Tests/Types/Workflow/BaseModelComponents.test.ts +429 -0
- package/Tests/Types/Workflow/Components/BaseModel.test.ts +225 -0
- package/Tests/Types/Workflow/IntegrationCredentialMetadata.test.ts +100 -0
- package/Tests/Types/Workflow/TemplateSyntax.test.ts +112 -0
- package/Tests/Types/Workflow/Templates.test.ts +916 -101
- package/Tests/UI/Components/ActiveFilterChipsOpenRoute.test.tsx +82 -0
- package/Tests/UI/Components/ComponentsModal.test.tsx +564 -7
- package/Tests/UI/Components/KeyboardShortcut.test.tsx +95 -0
- package/Tests/UI/Components/LogDetailsPanelCrossSignal.test.tsx +448 -0
- package/Tests/UI/Components/LogsTableCrossLinks.test.tsx +263 -0
- package/Tests/UI/Components/PendingProjectInvitations.test.tsx +913 -0
- package/Tests/UI/Components/SimpleLogViewer.test.tsx +228 -0
- package/Tests/UI/Components/TableRowSelectability.test.tsx +312 -0
- package/Tests/UI/Components/Workflow/GraphLint.test.ts +72 -0
- package/Tests/UI/Components/Workflow/GraphLintSummary.test.ts +755 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +242 -7
- package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +15 -7
- package/Tests/UI/Components/Workflow/ModelSchema.test.ts +242 -28
- package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +59 -1
- package/Tests/UI/Components/Workflow/StepTraceViewer.test.tsx +256 -0
- package/Tests/UI/Components/Workflow/UseRunWatch.test.tsx +665 -0
- package/Tests/UI/Components/Workflow/WorkflowIssuesModal.test.tsx +485 -0
- package/Tests/UI/Components/Workflow/WorkflowLogModal.test.tsx +478 -0
- package/Tests/UI/Components/Workflow/WorkflowStatusBar.test.tsx +379 -0
- package/Tests/UI/EsbuildConfig.test.ts +607 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +11 -4
- package/Tests/UI/Utils/ModelAPICreateMiscData.test.ts +94 -0
- package/Tests/UI/Utils/Platform.test.ts +147 -0
- package/Tests/UI/Utils/ProjectInvitationDisplay.test.ts +357 -0
- package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +198 -0
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +487 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +698 -0
- package/Tests/__mocks__/bullmq.js +55 -0
- package/Types/AI/AIChatMessageStatus.ts +8 -1
- package/Types/AI/AIChatTypes.ts +12 -0
- package/Types/Database/AccessControl/OwnerOnlyColumn.ts +88 -0
- package/Types/Exception/ExceptionCode.ts +2 -0
- package/Types/Exception/ServiceUnavailableException.ts +8 -0
- package/Types/Exception/TooManyRequestsException.ts +8 -0
- package/Types/IP/IP.ts +93 -47
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +49 -3
- package/Types/NetworkDevice/NetworkDeviceMonitoringMethod.ts +55 -0
- package/Types/OnCallDutyPolicy/Layer.ts +203 -149
- package/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.ts +13 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +155 -10
- package/Types/Permission.ts +193 -0
- package/Types/Telemetry/EntityRelationshipType.ts +1 -1
- package/Types/Telemetry/EntitySource.ts +1 -1
- package/Types/Telemetry/EntityType.ts +1 -1
- package/Types/Telemetry/EntityTypeGroups.ts +1 -1
- package/Types/Workflow/Components/BaseModel.ts +75 -29
- package/Types/Workflow/Components/Discord.ts +1 -0
- package/Types/Workflow/Components/Email.ts +12 -3
- package/Types/Workflow/Components/JavaScript.ts +7 -0
- package/Types/Workflow/Components/MicrosoftTeams.ts +3 -2
- package/Types/Workflow/Components/Slack.ts +1 -0
- package/Types/Workflow/Components/Telegram.ts +1 -0
- package/Types/Workflow/TemplateSyntax.ts +44 -0
- package/Types/Workflow/Templates.ts +2097 -45
- package/UI/Components/Calendar/Calendar.css +43 -0
- package/UI/Components/Calendar/Calendar.tsx +8 -0
- package/UI/Components/Card/Card.tsx +2 -2
- package/UI/Components/Checkbox/Checkbox.tsx +16 -0
- package/UI/Components/Dictionary/Dictionary.tsx +48 -9
- package/UI/Components/FormModal/BasicFormModal.tsx +2 -1
- package/UI/Components/Header/HeaderIconDropdownButton.tsx +53 -3
- package/UI/Components/Input/Input.tsx +1 -0
- package/UI/Components/KeyboardShortcut/KeyboardKey.ts +185 -0
- package/UI/Components/KeyboardShortcut/KeyboardShortcut.tsx +87 -0
- package/UI/Components/LogsViewer/LogsViewer.tsx +28 -0
- package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +31 -0
- package/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.tsx +18 -18
- package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +363 -14
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +11 -0
- package/UI/Components/LogsViewer/components/LogsTable.tsx +155 -12
- package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +29 -0
- package/UI/Components/LogsViewer/types.ts +23 -0
- package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +9 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +15 -30
- package/UI/Components/ProjectInvitations/PendingProjectInvitations.tsx +442 -0
- package/UI/Components/SimpleLogViewer/SimpleLogViewer.tsx +23 -1
- package/UI/Components/Table/Table.tsx +49 -16
- package/UI/Components/Table/TableBody.tsx +53 -28
- package/UI/Components/Table/TableHeader.tsx +13 -0
- package/UI/Components/Table/TableRow.tsx +58 -26
- package/UI/Components/Workflow/ArgumentsForm.tsx +155 -9
- package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +26 -0
- package/UI/Components/Workflow/ComponentSettingsModal.tsx +10 -1
- package/UI/Components/Workflow/ComponentValuePickerModal.tsx +135 -7
- package/UI/Components/Workflow/ComponentsModal.tsx +116 -22
- package/UI/Components/Workflow/DocumentationViewer.tsx +59 -9
- package/UI/Components/Workflow/GraphLint.ts +42 -5
- package/UI/Components/Workflow/GraphLintSummary.ts +390 -0
- package/UI/Components/Workflow/ModelColumnEditor.tsx +154 -22
- package/UI/Components/Workflow/ModelSchema.ts +115 -33
- package/UI/Components/Workflow/RunStatusWatcher.ts +1 -1
- package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
- package/UI/Components/Workflow/UseRunWatch.ts +212 -0
- package/UI/Components/Workflow/VariableModal.tsx +6 -2
- package/UI/Components/Workflow/Workflow.tsx +49 -3
- package/UI/Components/Workflow/WorkflowIssuesModal.tsx +255 -0
- package/UI/Components/Workflow/WorkflowLogModal.tsx +128 -0
- package/UI/Components/Workflow/WorkflowStatusBar.tsx +224 -0
- package/UI/Utils/AIChatExport/ConversationMarkdown.ts +10 -0
- package/UI/Utils/ModelAPI/ModelAPI.ts +7 -1
- package/UI/Utils/Platform.ts +149 -0
- package/UI/Utils/ProjectInvitationDisplay.ts +118 -0
- package/UI/esbuild-config.js +22 -1
- package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +187 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +888 -164
- package/Utils/Telemetry/CrossSignalScope.ts +502 -0
- package/Utils/Telemetry/EntityKey.ts +5 -5
- package/Utils/Telemetry/EntityRelationship.ts +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +1 -1
- package/build/dist/Models/DatabaseModels/AIConversation.js +32 -0
- package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AIConversationMessage.js +42 -0
- package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +28 -0
- package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +29 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +12 -4
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/{TelemetryEntity.js → InventoryItem.js} +167 -31
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -0
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js +454 -0
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js.map +1 -0
- package/build/dist/Models/DatabaseModels/{TelemetryEntityRelationship.js → InventoryItemRelationship.js} +27 -27
- package/build/dist/Models/DatabaseModels/InventoryItemRelationship.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +141 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +719 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +475 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js +446 -0
- package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js.map +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js +9 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +80 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserCall.js +46 -2
- package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserEmail.js +48 -2
- package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserNotificationRule.js +424 -55
- package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +24 -2
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserPush.js +51 -1
- package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserSMS.js +47 -2
- package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTelegram.js +65 -3
- package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWebhook.js +56 -2
- package/build/dist/Models/DatabaseModels/UserWebhook.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js +45 -2
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkflowVariable.js +12 -0
- package/build/dist/Models/DatabaseModels/WorkflowVariable.js.map +1 -1
- package/build/dist/Server/API/AIChatAPI.js +237 -1
- package/build/dist/Server/API/AIChatAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +165 -13
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/OnCallReadinessAPI.js +599 -0
- package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -0
- package/build/dist/Server/API/TeamComplianceAPI.js +68 -9
- package/build/dist/Server/API/TeamComplianceAPI.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +129 -18
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +45 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +22 -0
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +4 -4
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js +38 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js +22 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js +150 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js +57 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js +69 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js +32 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js +48 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/Queue.js +72 -13
- package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
- package/build/dist/Server/Middleware/PublicDashboardRateLimit.js +399 -0
- package/build/dist/Server/Middleware/PublicDashboardRateLimit.js.map +1 -0
- package/build/dist/Server/Services/AIService.js +1 -0
- package/build/dist/Server/Services/AIService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js +24 -3
- package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/AlertSeverityService.js +54 -0
- package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
- package/build/dist/Server/Services/DashboardService.js +10 -9
- package/build/dist/Server/Services/DashboardService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +24 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +24 -3
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentSeverityService.js +67 -0
- package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +12 -4
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemCustomFieldService.js +9 -0
- package/build/dist/Server/Services/InventoryItemCustomFieldService.js.map +1 -0
- package/build/dist/Server/Services/{TelemetryEntityRelationshipService.js → InventoryItemRelationshipService.js} +6 -6
- package/build/dist/Server/Services/InventoryItemRelationshipService.js.map +1 -0
- package/build/dist/Server/Services/{TelemetryEntityService.js → InventoryItemService.js} +68 -23
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -0
- package/build/dist/Server/Services/LogAggregationService.js +27 -8
- package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
- package/build/dist/Server/Services/MetricAggregationService.js +80 -0
- package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +6 -6
- package/build/dist/Server/Services/MetricService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js +9 -0
- package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceLinkService.js +71 -0
- package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceService.js +113 -0
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/NetworkSiteService.js +53 -13
- package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
- package/build/dist/Server/Services/NetworkTopologySuppressionService.js +85 -0
- package/build/dist/Server/Services/NetworkTopologySuppressionService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +48 -32
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +8 -0
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js +51 -12
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +57 -13
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallNotificationAlertingService.js +548 -0
- package/build/dist/Server/Services/OnCallNotificationAlertingService.js.map +1 -0
- package/build/dist/Server/Services/OnCallReadinessService.js +1961 -0
- package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -0
- package/build/dist/Server/Services/OnCallSetupReminderService.js +738 -0
- package/build/dist/Server/Services/OnCallSetupReminderService.js.map +1 -0
- package/build/dist/Server/Services/ProfileAggregationService.js +68 -4
- package/build/dist/Server/Services/ProfileAggregationService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +11 -10
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/TeamComplianceService.js +312 -160
- package/build/dist/Server/Services/TeamComplianceService.js.map +1 -1
- package/build/dist/Server/Services/UserCallService.js +24 -1
- package/build/dist/Server/Services/UserCallService.js.map +1 -1
- package/build/dist/Server/Services/UserEmailService.js +24 -1
- package/build/dist/Server/Services/UserEmailService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleAdminService.js +858 -0
- package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -0
- package/build/dist/Server/Services/UserNotificationRuleService.js +2830 -175
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallLogService.js +488 -43
- package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
- package/build/dist/Server/Services/UserPushService.js +26 -0
- package/build/dist/Server/Services/UserPushService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +10 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserSmsService.js +24 -1
- package/build/dist/Server/Services/UserSmsService.js.map +1 -1
- package/build/dist/Server/Services/UserTelegramService.js +22 -1
- package/build/dist/Server/Services/UserTelegramService.js.map +1 -1
- package/build/dist/Server/Services/UserWebhookService.js +25 -1
- package/build/dist/Server/Services/UserWebhookService.js.map +1 -1
- package/build/dist/Server/Services/UserWhatsAppService.js +22 -1
- package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js +12 -1
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/CreatePermission.js +126 -0
- package/build/dist/Server/Types/Database/Permissions/CreatePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js +254 -0
- package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js.map +1 -0
- package/build/dist/Server/Types/Database/Permissions/QueryPermission.js +47 -2
- package/build/dist/Server/Types/Database/Permissions/QueryPermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +7 -0
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Delete.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Delete.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Get.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Get.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Patch.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Patch.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Post.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Post.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Put.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Put.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js +21 -5
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js +14 -10
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js +31 -0
- package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +3 -9
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Email.js +15 -4
- package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js +7 -2
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +514 -56
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +20 -3
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +19 -6
- package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +7 -0
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js +2 -2
- package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js +692 -0
- package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +148 -12
- package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +157 -10
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/Index.js +37 -0
- package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +259 -14
- package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js +235 -0
- package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js +1000 -0
- package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js +356 -0
- package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +394 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js +465 -0
- package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js +280 -0
- package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js +527 -0
- package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js +548 -0
- package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js.map +1 -0
- package/build/dist/Server/Utils/ClientIp.js +137 -0
- package/build/dist/Server/Utils/ClientIp.js.map +1 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +38 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js +89 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js.map +1 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js +77 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js.map +1 -0
- package/build/dist/Server/Utils/Express.js +12 -12
- package/build/dist/Server/Utils/Express.js.map +1 -1
- package/build/dist/Server/Utils/LLM/LLMService.js +70 -7
- package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +121 -11
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/SSRFProtection.js +82 -21
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +12 -4
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +165 -18
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +11 -11
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +122 -47
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +7 -3
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/AI/AIChatMessageStatus.js +8 -1
- package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -1
- package/build/dist/Types/AI/AIChatTypes.js +12 -0
- package/build/dist/Types/AI/AIChatTypes.js.map +1 -1
- package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js +60 -0
- package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js.map +1 -0
- package/build/dist/Types/Exception/ExceptionCode.js +2 -0
- package/build/dist/Types/Exception/ExceptionCode.js.map +1 -1
- package/build/dist/Types/Exception/ServiceUnavailableException.js +8 -0
- package/build/dist/Types/Exception/ServiceUnavailableException.js.map +1 -0
- package/build/dist/Types/Exception/TooManyRequestsException.js +8 -0
- package/build/dist/Types/Exception/TooManyRequestsException.js.map +1 -0
- package/build/dist/Types/IP/IP.js +87 -43
- package/build/dist/Types/IP/IP.js.map +1 -1
- package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js +50 -0
- package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +186 -123
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js +13 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +105 -11
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/Permission.js +174 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityRelationshipType.js +1 -1
- package/build/dist/Types/Telemetry/EntitySource.js +1 -1
- package/build/dist/Types/Telemetry/EntityType.js +1 -1
- package/build/dist/Types/Telemetry/EntityTypeGroups.js +1 -1
- package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -1
- package/build/dist/Types/Workflow/Components/BaseModel.js +66 -28
- package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Discord.js +1 -0
- package/build/dist/Types/Workflow/Components/Discord.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Email.js +12 -3
- package/build/dist/Types/Workflow/Components/Email.js.map +1 -1
- package/build/dist/Types/Workflow/Components/JavaScript.js +7 -0
- package/build/dist/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Types/Workflow/Components/MicrosoftTeams.js +3 -2
- package/build/dist/Types/Workflow/Components/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Slack.js +1 -0
- package/build/dist/Types/Workflow/Components/Slack.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Telegram.js +1 -0
- package/build/dist/Types/Workflow/Components/Telegram.js.map +1 -1
- package/build/dist/Types/Workflow/TemplateSyntax.js +12 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -1
- package/build/dist/Types/Workflow/Templates.js +1931 -38
- package/build/dist/Types/Workflow/Templates.js.map +1 -1
- package/build/dist/UI/Components/Calendar/Calendar.js +1 -1
- package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
- package/build/dist/UI/Components/Checkbox/Checkbox.js +1 -1
- package/build/dist/UI/Components/Checkbox/Checkbox.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/Dictionary.js +25 -11
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +27 -4
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +1 -0
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js +163 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js.map +1 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js +47 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js.map +1 -0
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +4 -4
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +11 -1
- package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js +10 -8
- package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +227 -14
- package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +5 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +69 -5
- package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +8 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +9 -2
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +8 -21
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js +251 -0
- package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js.map +1 -0
- package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js +9 -2
- package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js.map +1 -1
- package/build/dist/UI/Components/Table/Table.js +27 -15
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Components/Table/TableBody.js +24 -18
- package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +9 -1
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Table/TableRow.js +29 -21
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +130 -12
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js +10 -1
- package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +4 -4
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js +57 -7
- package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentsModal.js +53 -18
- package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/DocumentationViewer.js +19 -6
- package/build/dist/UI/Components/Workflow/DocumentationViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/GraphLint.js +33 -4
- package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -1
- package/build/dist/UI/Components/Workflow/GraphLintSummary.js +231 -0
- package/build/dist/UI/Components/Workflow/GraphLintSummary.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +116 -21
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ModelSchema.js +72 -34
- package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -1
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +1 -1
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -1
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/UseRunWatch.js +123 -0
- package/build/dist/UI/Components/Workflow/UseRunWatch.js.map +1 -0
- package/build/dist/UI/Components/Workflow/VariableModal.js +3 -2
- package/build/dist/UI/Components/Workflow/VariableModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/Workflow.js +41 -1
- package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
- package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js +99 -0
- package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js.map +1 -0
- package/build/dist/UI/Components/Workflow/WorkflowLogModal.js +56 -0
- package/build/dist/UI/Components/Workflow/WorkflowLogModal.js.map +1 -0
- package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js +92 -0
- package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js.map +1 -0
- package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js +9 -0
- package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
- package/build/dist/UI/Utils/Platform.js +118 -0
- package/build/dist/UI/Utils/Platform.js.map +1 -0
- package/build/dist/UI/Utils/ProjectInvitationDisplay.js +106 -0
- package/build/dist/UI/Utils/ProjectInvitationDisplay.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +108 -0
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +642 -136
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +328 -0
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -0
- package/build/dist/Utils/Telemetry/EntityKey.js +5 -5
- package/build/dist/Utils/Telemetry/EntityRelationship.js +1 -1
- package/jest.config.json +1 -0
- package/package.json +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +0 -1
- package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +0 -1
- package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +0 -1
- package/build/dist/Server/Services/TelemetryEntityService.js.map +0 -1
|
@@ -0,0 +1,4295 @@
|
|
|
1
|
+
import AlertSeverityService from "../../../Server/Services/AlertSeverityService";
|
|
2
|
+
import IncidentSeverityService from "../../../Server/Services/IncidentSeverityService";
|
|
3
|
+
import OnCallDutyPolicyEscalationRuleScheduleService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleScheduleService";
|
|
4
|
+
import OnCallDutyPolicyEscalationRuleTeamService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleTeamService";
|
|
5
|
+
import OnCallDutyPolicyEscalationRuleUserService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleUserService";
|
|
6
|
+
import OnCallDutyPolicyScheduleLayerUserService from "../../../Server/Services/OnCallDutyPolicyScheduleLayerUserService";
|
|
7
|
+
import OnCallDutyPolicyService from "../../../Server/Services/OnCallDutyPolicyService";
|
|
8
|
+
import OnCallDutyPolicyUserOverrideService from "../../../Server/Services/OnCallDutyPolicyUserOverrideService";
|
|
9
|
+
import OnCallReadinessService, {
|
|
10
|
+
IDENTIFIER_MASK,
|
|
11
|
+
MaskedIdentifierKind,
|
|
12
|
+
ReadinessCoverageCell,
|
|
13
|
+
ReadinessMethod,
|
|
14
|
+
ReadinessMethodType,
|
|
15
|
+
ReadinessStatus,
|
|
16
|
+
ReadinessSummary,
|
|
17
|
+
ReadinessTeam,
|
|
18
|
+
ResponderSource,
|
|
19
|
+
UserReadiness,
|
|
20
|
+
maskIdentifier,
|
|
21
|
+
} from "../../../Server/Services/OnCallReadinessService";
|
|
22
|
+
import ProjectService from "../../../Server/Services/ProjectService";
|
|
23
|
+
import TeamMemberService from "../../../Server/Services/TeamMemberService";
|
|
24
|
+
import TeamService from "../../../Server/Services/TeamService";
|
|
25
|
+
import UserCallService from "../../../Server/Services/UserCallService";
|
|
26
|
+
import UserEmailService from "../../../Server/Services/UserEmailService";
|
|
27
|
+
import UserNotificationRuleService from "../../../Server/Services/UserNotificationRuleService";
|
|
28
|
+
import UserPushService from "../../../Server/Services/UserPushService";
|
|
29
|
+
import UserService from "../../../Server/Services/UserService";
|
|
30
|
+
import UserSmsService from "../../../Server/Services/UserSmsService";
|
|
31
|
+
import UserTelegramService from "../../../Server/Services/UserTelegramService";
|
|
32
|
+
import UserWebhookService from "../../../Server/Services/UserWebhookService";
|
|
33
|
+
import UserWhatsAppService from "../../../Server/Services/UserWhatsAppService";
|
|
34
|
+
import logger from "../../../Server/Utils/Logger";
|
|
35
|
+
import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
|
|
36
|
+
import IncidentSeverity from "../../../Models/DatabaseModels/IncidentSeverity";
|
|
37
|
+
import OnCallDutyPolicy from "../../../Models/DatabaseModels/OnCallDutyPolicy";
|
|
38
|
+
import OnCallDutyPolicyEscalationRuleSchedule from "../../../Models/DatabaseModels/OnCallDutyPolicyEscalationRuleSchedule";
|
|
39
|
+
import OnCallDutyPolicyEscalationRuleTeam from "../../../Models/DatabaseModels/OnCallDutyPolicyEscalationRuleTeam";
|
|
40
|
+
import OnCallDutyPolicyEscalationRuleUser from "../../../Models/DatabaseModels/OnCallDutyPolicyEscalationRuleUser";
|
|
41
|
+
import OnCallDutyPolicyScheduleLayerUser from "../../../Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser";
|
|
42
|
+
import OnCallDutyPolicyUserOverride from "../../../Models/DatabaseModels/OnCallDutyPolicyUserOverride";
|
|
43
|
+
import Project from "../../../Models/DatabaseModels/Project";
|
|
44
|
+
import Team from "../../../Models/DatabaseModels/Team";
|
|
45
|
+
import TeamMember from "../../../Models/DatabaseModels/TeamMember";
|
|
46
|
+
import User from "../../../Models/DatabaseModels/User";
|
|
47
|
+
import UserCall from "../../../Models/DatabaseModels/UserCall";
|
|
48
|
+
import UserEmail from "../../../Models/DatabaseModels/UserEmail";
|
|
49
|
+
import UserNotificationRule from "../../../Models/DatabaseModels/UserNotificationRule";
|
|
50
|
+
import UserPush from "../../../Models/DatabaseModels/UserPush";
|
|
51
|
+
import UserSMS from "../../../Models/DatabaseModels/UserSMS";
|
|
52
|
+
import UserTelegram from "../../../Models/DatabaseModels/UserTelegram";
|
|
53
|
+
import UserWebhook from "../../../Models/DatabaseModels/UserWebhook";
|
|
54
|
+
import UserWhatsApp from "../../../Models/DatabaseModels/UserWhatsApp";
|
|
55
|
+
import Includes from "../../../Types/BaseDatabase/Includes";
|
|
56
|
+
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
|
57
|
+
import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
|
|
58
|
+
import Email from "../../../Types/Email";
|
|
59
|
+
import Name from "../../../Types/Name";
|
|
60
|
+
import NotificationRuleType from "../../../Types/NotificationRule/NotificationRuleType";
|
|
61
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
62
|
+
import Phone from "../../../Types/Phone";
|
|
63
|
+
import { FindOperator } from "typeorm";
|
|
64
|
+
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* OnCallReadinessService answers exactly one question - "can this responder
|
|
68
|
+
* actually be paged?" - and every readiness surface in the product renders what
|
|
69
|
+
* it returns. That makes each of its judgements load-bearing in the most
|
|
70
|
+
* literal sense: a false green is a page that lands nowhere while an admin
|
|
71
|
+
* looks at a table telling them everything is fine.
|
|
72
|
+
*
|
|
73
|
+
* It replaces TeamComplianceService, which answered the same question wrongly
|
|
74
|
+
* in seven separate ways. Six of those seven are structural properties of this
|
|
75
|
+
* service rather than behaviours you would stumble across, so they are pinned
|
|
76
|
+
* here deliberately and each test says which defect it is standing guard over:
|
|
77
|
+
*
|
|
78
|
+
* - team-scoping (a direct/schedule/override responder was never checked)
|
|
79
|
+
* - a bare `limit: 100` that silently truncated a real project
|
|
80
|
+
* - matching on severity alone, so a "when I go off call" rule certified
|
|
81
|
+
* incident coverage
|
|
82
|
+
* - counting only four of the seven channels, so a Telegram-only responder
|
|
83
|
+
* was reported unreachable while the runtime paged them happily
|
|
84
|
+
* - one findBy per severity per user
|
|
85
|
+
* - being opt-in and off by default
|
|
86
|
+
*
|
|
87
|
+
* Three further properties are pinned because getting them wrong is silent and
|
|
88
|
+
* expensive rather than merely wrong:
|
|
89
|
+
*
|
|
90
|
+
* MASKING. Every identifier this service emits is redacted server-side, and
|
|
91
|
+
* the tests assert on the SERIALIZED summary rather than on the fields a test
|
|
92
|
+
* author happened to remember - a leak that arrives through a field nobody
|
|
93
|
+
* listed is exactly the leak that ships.
|
|
94
|
+
*
|
|
95
|
+
* THE OPT-OUT PREDICATE. `isOptOut` is nullable and was added long after
|
|
96
|
+
* these rows started existing, so it is NULL on every pre-existing rule. The
|
|
97
|
+
* naive `isOptOut === false` split would classify every one of them as
|
|
98
|
+
* neither a rule nor an opt-out and report a fully-configured project as
|
|
99
|
+
* entirely unready. There is a test whose whole job is to fail if anyone
|
|
100
|
+
* writes that.
|
|
101
|
+
*
|
|
102
|
+
* THE SEVERITY MODEL PER RULE TYPE. Incident and incident-episode rules are
|
|
103
|
+
* scoped by IncidentSeverity, alert and alert-episode by AlertSeverity.
|
|
104
|
+
* Crossing them produces cells that can never match anything, which is the
|
|
105
|
+
* exact shape of Gap G - rules that were invisible and unreachable at the
|
|
106
|
+
* same time.
|
|
107
|
+
*
|
|
108
|
+
* Nothing here touches a database. Every read is a jest.spyOn at the service
|
|
109
|
+
* boundary, and the fixtures are ordinary models, so what is under test is this
|
|
110
|
+
* service's own decision-making.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
114
|
+
"11111111-1111-4111-8111-111111111111",
|
|
115
|
+
);
|
|
116
|
+
const OTHER_PROJECT_ID: ObjectID = new ObjectID(
|
|
117
|
+
"1e1e1e1e-1e1e-4e1e-8e1e-1e1e1e1e1e1e",
|
|
118
|
+
);
|
|
119
|
+
const POLICY_ID: ObjectID = new ObjectID(
|
|
120
|
+
"22222222-2222-4222-8222-222222222222",
|
|
121
|
+
);
|
|
122
|
+
const OTHER_POLICY_ID: ObjectID = new ObjectID(
|
|
123
|
+
"2f2f2f2f-2f2f-4f2f-8f2f-2f2f2f2f2f2f",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const USER_A_ID: ObjectID = new ObjectID(
|
|
127
|
+
"33333333-3333-4333-8333-333333333333",
|
|
128
|
+
);
|
|
129
|
+
const USER_B_ID: ObjectID = new ObjectID(
|
|
130
|
+
"44444444-4444-4444-8444-444444444444",
|
|
131
|
+
);
|
|
132
|
+
const USER_C_ID: ObjectID = new ObjectID(
|
|
133
|
+
"55555555-5555-4555-8555-555555555555",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const TEAM_ID: ObjectID = new ObjectID("66666666-6666-4666-8666-666666666666");
|
|
137
|
+
const OTHER_TEAM_ID: ObjectID = new ObjectID(
|
|
138
|
+
"6a6a6a6a-6a6a-4a6a-8a6a-6a6a6a6a6a6a",
|
|
139
|
+
);
|
|
140
|
+
const SCHEDULE_ID: ObjectID = new ObjectID(
|
|
141
|
+
"77777777-7777-4777-8777-777777777777",
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const INCIDENT_SEVERITY_1_ID: ObjectID = new ObjectID(
|
|
145
|
+
"88888888-8888-4888-8888-888888888888",
|
|
146
|
+
);
|
|
147
|
+
const INCIDENT_SEVERITY_2_ID: ObjectID = new ObjectID(
|
|
148
|
+
"99999999-9999-4999-8999-999999999999",
|
|
149
|
+
);
|
|
150
|
+
const ALERT_SEVERITY_1_ID: ObjectID = new ObjectID(
|
|
151
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
152
|
+
);
|
|
153
|
+
const ALERT_SEVERITY_2_ID: ObjectID = new ObjectID(
|
|
154
|
+
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* One id per channel for the METHOD ROWS themselves - UserSMS._id, UserEmail._id and so
|
|
159
|
+
* on - which are what UserNotificationRule.userSmsId / userEmailId / ... reference and
|
|
160
|
+
* what the admin rule form has to submit.
|
|
161
|
+
*
|
|
162
|
+
* They are all distinct from each other AND from every user id above, deliberately: the
|
|
163
|
+
* mistake this pins against is wiring `row.userId` into the payload instead of `row.id`,
|
|
164
|
+
* which type-checks perfectly, renders perfectly, and produces a rule pointed at a row
|
|
165
|
+
* that is not a notification method at all.
|
|
166
|
+
*/
|
|
167
|
+
const PUSH_METHOD_ID: ObjectID = new ObjectID(
|
|
168
|
+
"c0000000-0000-4000-8000-000000000001",
|
|
169
|
+
);
|
|
170
|
+
const EMAIL_METHOD_ID: ObjectID = new ObjectID(
|
|
171
|
+
"c0000000-0000-4000-8000-000000000002",
|
|
172
|
+
);
|
|
173
|
+
const SMS_METHOD_ID: ObjectID = new ObjectID(
|
|
174
|
+
"c0000000-0000-4000-8000-000000000003",
|
|
175
|
+
);
|
|
176
|
+
const CALL_METHOD_ID: ObjectID = new ObjectID(
|
|
177
|
+
"c0000000-0000-4000-8000-000000000004",
|
|
178
|
+
);
|
|
179
|
+
const WHATSAPP_METHOD_ID: ObjectID = new ObjectID(
|
|
180
|
+
"c0000000-0000-4000-8000-000000000005",
|
|
181
|
+
);
|
|
182
|
+
const TELEGRAM_METHOD_ID: ObjectID = new ObjectID(
|
|
183
|
+
"c0000000-0000-4000-8000-000000000006",
|
|
184
|
+
);
|
|
185
|
+
const WEBHOOK_METHOD_ID: ObjectID = new ObjectID(
|
|
186
|
+
"c0000000-0000-4000-8000-000000000007",
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
* Raw identifiers that must never reach a caller. Each is deliberately
|
|
191
|
+
* distinctive so a substring search over the serialized summary cannot pass by
|
|
192
|
+
* accident, and each has a "revealing middle" that survives no legitimate mask.
|
|
193
|
+
*/
|
|
194
|
+
const RAW_NOTIFICATION_EMAIL: string = "ada.lovelace@analytical-engine.example";
|
|
195
|
+
const RAW_SMS_PHONE: string = "+14155554821";
|
|
196
|
+
const RAW_CALL_PHONE: string = "+442071838750";
|
|
197
|
+
const RAW_WHATSAPP_PHONE: string = "+61293744000";
|
|
198
|
+
const RAW_TELEGRAM_HANDLE: string = "@ada_night_pager";
|
|
199
|
+
const RAW_WEBHOOK_NAME: string = "Payments Incident Bridge";
|
|
200
|
+
const RAW_WEBHOOK_URL: string =
|
|
201
|
+
"https://hooks.example.com/T0LEAK/B0LEAK/xoxbSUPERSECRETTOKEN";
|
|
202
|
+
const RAW_PUSH_DEVICE: string = "Ada iPhone 15 Pro Max";
|
|
203
|
+
|
|
204
|
+
/** The login email, which the frozen contract says is deliberately NOT masked. */
|
|
205
|
+
const USER_A_LOGIN_EMAIL: string = "ada@corp.example.com";
|
|
206
|
+
|
|
207
|
+
/*
|
|
208
|
+
* Enough of a findBy argument to assert on without importing the FindBy
|
|
209
|
+
* generics. `query` is deliberately a Record so that assertions have to use
|
|
210
|
+
* bracket access and cannot accidentally rely on a typed field that the
|
|
211
|
+
* production code never set.
|
|
212
|
+
*/
|
|
213
|
+
interface FindByCall {
|
|
214
|
+
query: Record<string, unknown>;
|
|
215
|
+
select?: Record<string, unknown> | undefined;
|
|
216
|
+
sort?: Record<string, unknown> | undefined;
|
|
217
|
+
limit?: number | undefined;
|
|
218
|
+
skip?: number | undefined;
|
|
219
|
+
props?: { isRoot?: boolean } | undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface UserFindByArgument {
|
|
223
|
+
query: { _id?: Includes | undefined };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface TeamMemberFindByArgument {
|
|
227
|
+
query: { teamId?: Includes | undefined; userId?: Includes | undefined };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface TeamFindByArgument {
|
|
231
|
+
query: { _id?: Includes | undefined };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/*
|
|
235
|
+
* The mutable world every spy reads from. Reset wholesale in beforeEach so no
|
|
236
|
+
* test can inherit another's configuration - readiness is a whole-project
|
|
237
|
+
* roll-up and a leaked responder would change a count somewhere far away.
|
|
238
|
+
*/
|
|
239
|
+
let userDirectory: Array<User> = [];
|
|
240
|
+
let membershipRows: Array<TeamMember> = [];
|
|
241
|
+
let teamMemberRows: Array<TeamMember> = [];
|
|
242
|
+
/*
|
|
243
|
+
* Every team that exists, by id. The service reads names for the teams that page
|
|
244
|
+
* somebody; a team missing from here is a team whose row did not come back,
|
|
245
|
+
* which is a state the contract has an opinion about.
|
|
246
|
+
*/
|
|
247
|
+
let teamDirectory: Array<Team> = [];
|
|
248
|
+
|
|
249
|
+
let policyFindOneById: jest.SpyInstance;
|
|
250
|
+
let escalationUserFindBy: jest.SpyInstance;
|
|
251
|
+
let escalationTeamFindBy: jest.SpyInstance;
|
|
252
|
+
let escalationScheduleFindBy: jest.SpyInstance;
|
|
253
|
+
let scheduleLayerUserFindBy: jest.SpyInstance;
|
|
254
|
+
let overrideFindBy: jest.SpyInstance;
|
|
255
|
+
let teamMemberFindBy: jest.SpyInstance;
|
|
256
|
+
let teamFindBy: jest.SpyInstance;
|
|
257
|
+
let userFindBy: jest.SpyInstance;
|
|
258
|
+
let pushFindBy: jest.SpyInstance;
|
|
259
|
+
let emailFindBy: jest.SpyInstance;
|
|
260
|
+
let smsFindBy: jest.SpyInstance;
|
|
261
|
+
let callFindBy: jest.SpyInstance;
|
|
262
|
+
let whatsAppFindBy: jest.SpyInstance;
|
|
263
|
+
let telegramFindBy: jest.SpyInstance;
|
|
264
|
+
let webhookFindBy: jest.SpyInstance;
|
|
265
|
+
let notificationRuleFindBy: jest.SpyInstance;
|
|
266
|
+
let incidentSeverityFindBy: jest.SpyInstance;
|
|
267
|
+
let alertSeverityFindBy: jest.SpyInstance;
|
|
268
|
+
let projectFindOneById: jest.SpyInstance;
|
|
269
|
+
|
|
270
|
+
function makeUser(id: ObjectID, name: string, loginEmail: string): User {
|
|
271
|
+
const user: User = new User();
|
|
272
|
+
user.id = id;
|
|
273
|
+
user.name = new Name(name);
|
|
274
|
+
user.email = new Email(loginEmail);
|
|
275
|
+
|
|
276
|
+
return user;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function makePolicy(projectId: ObjectID): OnCallDutyPolicy {
|
|
280
|
+
const policy: OnCallDutyPolicy = new OnCallDutyPolicy();
|
|
281
|
+
policy.id = POLICY_ID;
|
|
282
|
+
policy.projectId = projectId;
|
|
283
|
+
|
|
284
|
+
return policy;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function makeProject(
|
|
288
|
+
overrides: Partial<{
|
|
289
|
+
disableOnCallNotificationFallback: boolean;
|
|
290
|
+
enableSmsNotifications: boolean;
|
|
291
|
+
enableCallNotifications: boolean;
|
|
292
|
+
enableWhatsAppNotifications: boolean;
|
|
293
|
+
enableTelegramNotifications: boolean;
|
|
294
|
+
}> = {},
|
|
295
|
+
): Project {
|
|
296
|
+
const project: Project = new Project();
|
|
297
|
+
project.id = PROJECT_ID;
|
|
298
|
+
project.disableOnCallNotificationFallback =
|
|
299
|
+
overrides.disableOnCallNotificationFallback === true;
|
|
300
|
+
project.enableSmsNotifications = overrides.enableSmsNotifications !== false;
|
|
301
|
+
project.enableCallNotifications = overrides.enableCallNotifications !== false;
|
|
302
|
+
project.enableWhatsAppNotifications =
|
|
303
|
+
overrides.enableWhatsAppNotifications !== false;
|
|
304
|
+
project.enableTelegramNotifications =
|
|
305
|
+
overrides.enableTelegramNotifications !== false;
|
|
306
|
+
|
|
307
|
+
return project;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function escalationUserRow(
|
|
311
|
+
userId: ObjectID | undefined,
|
|
312
|
+
): OnCallDutyPolicyEscalationRuleUser {
|
|
313
|
+
const row: OnCallDutyPolicyEscalationRuleUser =
|
|
314
|
+
new OnCallDutyPolicyEscalationRuleUser();
|
|
315
|
+
|
|
316
|
+
if (userId) {
|
|
317
|
+
row.userId = userId;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return row;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function escalationTeamRow(
|
|
324
|
+
teamId: ObjectID,
|
|
325
|
+
): OnCallDutyPolicyEscalationRuleTeam {
|
|
326
|
+
const row: OnCallDutyPolicyEscalationRuleTeam =
|
|
327
|
+
new OnCallDutyPolicyEscalationRuleTeam();
|
|
328
|
+
row.teamId = teamId;
|
|
329
|
+
|
|
330
|
+
return row;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function escalationScheduleRow(
|
|
334
|
+
scheduleId: ObjectID,
|
|
335
|
+
): OnCallDutyPolicyEscalationRuleSchedule {
|
|
336
|
+
const row: OnCallDutyPolicyEscalationRuleSchedule =
|
|
337
|
+
new OnCallDutyPolicyEscalationRuleSchedule();
|
|
338
|
+
row.onCallDutyPolicyScheduleId = scheduleId;
|
|
339
|
+
|
|
340
|
+
return row;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function layerUserRow(
|
|
344
|
+
userId: ObjectID,
|
|
345
|
+
scheduleId?: ObjectID | undefined,
|
|
346
|
+
): OnCallDutyPolicyScheduleLayerUser {
|
|
347
|
+
const row: OnCallDutyPolicyScheduleLayerUser =
|
|
348
|
+
new OnCallDutyPolicyScheduleLayerUser();
|
|
349
|
+
row.userId = userId;
|
|
350
|
+
|
|
351
|
+
/*
|
|
352
|
+
* The schedule id matters only on the user-scoped path, which asks "which
|
|
353
|
+
* schedules is this person on" and then "are any of those attached to a
|
|
354
|
+
* rule". The project-scoped path arrives from the other direction and reads
|
|
355
|
+
* only the userId.
|
|
356
|
+
*/
|
|
357
|
+
if (scheduleId) {
|
|
358
|
+
row.onCallDutyPolicyScheduleId = scheduleId;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return row;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function overrideRow(
|
|
365
|
+
routeAlertsToUserId: ObjectID | undefined,
|
|
366
|
+
): OnCallDutyPolicyUserOverride {
|
|
367
|
+
const row: OnCallDutyPolicyUserOverride = new OnCallDutyPolicyUserOverride();
|
|
368
|
+
|
|
369
|
+
if (routeAlertsToUserId) {
|
|
370
|
+
row.routeAlertsToUserId = routeAlertsToUserId;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return row;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/*
|
|
377
|
+
* A membership row carries its teamId as well as its userId, because the
|
|
378
|
+
* service reads both out of one query: membership IS the cross-project guard,
|
|
379
|
+
* and the teamIds that come back with it are how one user's Team source is
|
|
380
|
+
* resolved without expanding every team in the project into its full roster.
|
|
381
|
+
*/
|
|
382
|
+
function teamMemberRow(
|
|
383
|
+
userId: ObjectID,
|
|
384
|
+
teamId?: ObjectID | undefined,
|
|
385
|
+
): TeamMember {
|
|
386
|
+
const row: TeamMember = new TeamMember();
|
|
387
|
+
row.userId = userId;
|
|
388
|
+
|
|
389
|
+
if (teamId) {
|
|
390
|
+
row.teamId = teamId;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return row;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function teamRow(id: ObjectID, name: string): Team {
|
|
397
|
+
const row: Team = new Team();
|
|
398
|
+
row.id = id;
|
|
399
|
+
row.name = name;
|
|
400
|
+
|
|
401
|
+
return row;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function incidentSeverity(id: ObjectID, name: string): IncidentSeverity {
|
|
405
|
+
const severity: IncidentSeverity = new IncidentSeverity();
|
|
406
|
+
severity.id = id;
|
|
407
|
+
severity.name = name;
|
|
408
|
+
|
|
409
|
+
return severity;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function alertSeverity(id: ObjectID, name: string): AlertSeverity {
|
|
413
|
+
const severity: AlertSeverity = new AlertSeverity();
|
|
414
|
+
severity.id = id;
|
|
415
|
+
severity.name = name;
|
|
416
|
+
|
|
417
|
+
return severity;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/*
|
|
421
|
+
* Every method fixture below carries an id, defaulting to a fresh one, because every
|
|
422
|
+
* method ROW carries one: `_id` is the primary key and every select in the service asks
|
|
423
|
+
* for it. A fixture without an id is not a row that could exist, and the service drops
|
|
424
|
+
* such a row rather than emitting a method the rule form could not point at - so a
|
|
425
|
+
* default here keeps that deliberate drop from quietly deleting the methods out of every
|
|
426
|
+
* unrelated test. The tests that care about a specific id pass one of the constants above.
|
|
427
|
+
*/
|
|
428
|
+
function pushMethod(data: {
|
|
429
|
+
userId: ObjectID;
|
|
430
|
+
deviceName?: string | undefined;
|
|
431
|
+
isVerified: boolean;
|
|
432
|
+
id?: ObjectID | undefined;
|
|
433
|
+
}): UserPush {
|
|
434
|
+
const model: UserPush = new UserPush();
|
|
435
|
+
model.id = data.id || ObjectID.generate();
|
|
436
|
+
model.userId = data.userId;
|
|
437
|
+
model.isVerified = data.isVerified;
|
|
438
|
+
|
|
439
|
+
if (data.deviceName !== undefined) {
|
|
440
|
+
model.deviceName = data.deviceName;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return model;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function emailMethod(data: {
|
|
447
|
+
userId: ObjectID;
|
|
448
|
+
email: string;
|
|
449
|
+
isVerified: boolean;
|
|
450
|
+
id?: ObjectID | undefined;
|
|
451
|
+
}): UserEmail {
|
|
452
|
+
const model: UserEmail = new UserEmail();
|
|
453
|
+
model.id = data.id || ObjectID.generate();
|
|
454
|
+
model.userId = data.userId;
|
|
455
|
+
model.email = new Email(data.email);
|
|
456
|
+
model.isVerified = data.isVerified;
|
|
457
|
+
|
|
458
|
+
return model;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function smsMethod(data: {
|
|
462
|
+
userId: ObjectID;
|
|
463
|
+
phone: string;
|
|
464
|
+
isVerified: boolean;
|
|
465
|
+
id?: ObjectID | undefined;
|
|
466
|
+
}): UserSMS {
|
|
467
|
+
const model: UserSMS = new UserSMS();
|
|
468
|
+
model.id = data.id || ObjectID.generate();
|
|
469
|
+
model.userId = data.userId;
|
|
470
|
+
model.phone = new Phone(data.phone);
|
|
471
|
+
model.isVerified = data.isVerified;
|
|
472
|
+
|
|
473
|
+
return model;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function callMethod(data: {
|
|
477
|
+
userId: ObjectID;
|
|
478
|
+
phone: string;
|
|
479
|
+
isVerified: boolean;
|
|
480
|
+
id?: ObjectID | undefined;
|
|
481
|
+
}): UserCall {
|
|
482
|
+
const model: UserCall = new UserCall();
|
|
483
|
+
model.id = data.id || ObjectID.generate();
|
|
484
|
+
model.userId = data.userId;
|
|
485
|
+
model.phone = new Phone(data.phone);
|
|
486
|
+
model.isVerified = data.isVerified;
|
|
487
|
+
|
|
488
|
+
return model;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function whatsAppMethod(data: {
|
|
492
|
+
userId: ObjectID;
|
|
493
|
+
phone: string;
|
|
494
|
+
isVerified: boolean;
|
|
495
|
+
id?: ObjectID | undefined;
|
|
496
|
+
}): UserWhatsApp {
|
|
497
|
+
const model: UserWhatsApp = new UserWhatsApp();
|
|
498
|
+
model.id = data.id || ObjectID.generate();
|
|
499
|
+
model.userId = data.userId;
|
|
500
|
+
model.phone = new Phone(data.phone);
|
|
501
|
+
model.isVerified = data.isVerified;
|
|
502
|
+
|
|
503
|
+
return model;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function telegramMethod(data: {
|
|
507
|
+
userId: ObjectID;
|
|
508
|
+
handle: string;
|
|
509
|
+
isVerified: boolean;
|
|
510
|
+
id?: ObjectID | undefined;
|
|
511
|
+
}): UserTelegram {
|
|
512
|
+
const model: UserTelegram = new UserTelegram();
|
|
513
|
+
model.id = data.id || ObjectID.generate();
|
|
514
|
+
model.userId = data.userId;
|
|
515
|
+
model.telegramUserHandle = data.handle;
|
|
516
|
+
model.isVerified = data.isVerified;
|
|
517
|
+
|
|
518
|
+
return model;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/*
|
|
522
|
+
* A webhook fixture that deliberately CARRIES its bearer url, even though the
|
|
523
|
+
* production select must never ask for it. If the select ever widens, the
|
|
524
|
+
* leak assertions downstream see a real credential rather than an undefined.
|
|
525
|
+
*/
|
|
526
|
+
function webhookMethod(data: {
|
|
527
|
+
userId: ObjectID;
|
|
528
|
+
name: string;
|
|
529
|
+
webhookUrl?: string | undefined;
|
|
530
|
+
id?: ObjectID | undefined;
|
|
531
|
+
}): UserWebhook {
|
|
532
|
+
const model: UserWebhook = new UserWebhook();
|
|
533
|
+
model.id = data.id || ObjectID.generate();
|
|
534
|
+
model.userId = data.userId;
|
|
535
|
+
model.name = data.name;
|
|
536
|
+
|
|
537
|
+
if (data.webhookUrl !== undefined) {
|
|
538
|
+
model.webhookUrl = data.webhookUrl;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return model;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function notificationRule(data: {
|
|
545
|
+
userId: ObjectID;
|
|
546
|
+
ruleType: NotificationRuleType;
|
|
547
|
+
incidentSeverityId?: ObjectID | undefined;
|
|
548
|
+
alertSeverityId?: ObjectID | undefined;
|
|
549
|
+
isOptOut?: boolean | undefined;
|
|
550
|
+
}): UserNotificationRule {
|
|
551
|
+
const rule: UserNotificationRule = new UserNotificationRule();
|
|
552
|
+
rule.userId = data.userId;
|
|
553
|
+
rule.ruleType = data.ruleType;
|
|
554
|
+
|
|
555
|
+
if (data.incidentSeverityId) {
|
|
556
|
+
rule.incidentSeverityId = data.incidentSeverityId;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (data.alertSeverityId) {
|
|
560
|
+
rule.alertSeverityId = data.alertSeverityId;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (data.isOptOut !== undefined) {
|
|
564
|
+
rule.isOptOut = data.isOptOut;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return rule;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function firstCall(spy: jest.SpyInstance): FindByCall {
|
|
571
|
+
return spy.mock.calls[0]![0] as FindByCall;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function callAt(spy: jest.SpyInstance, index: number): FindByCall {
|
|
575
|
+
return spy.mock.calls[index]![0] as FindByCall;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function includedIds(includes: unknown): Array<string> {
|
|
579
|
+
const values: Array<string | ObjectID | number> = (includes as Includes)
|
|
580
|
+
.values as Array<string | ObjectID | number>;
|
|
581
|
+
|
|
582
|
+
return values.map((value: string | ObjectID | number): string => {
|
|
583
|
+
return value.toString();
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Every read the service can make, so a test can count them as one number. */
|
|
588
|
+
function everySpy(): Array<jest.SpyInstance> {
|
|
589
|
+
return [
|
|
590
|
+
policyFindOneById,
|
|
591
|
+
escalationUserFindBy,
|
|
592
|
+
escalationTeamFindBy,
|
|
593
|
+
escalationScheduleFindBy,
|
|
594
|
+
scheduleLayerUserFindBy,
|
|
595
|
+
overrideFindBy,
|
|
596
|
+
teamMemberFindBy,
|
|
597
|
+
teamFindBy,
|
|
598
|
+
userFindBy,
|
|
599
|
+
pushFindBy,
|
|
600
|
+
emailFindBy,
|
|
601
|
+
smsFindBy,
|
|
602
|
+
callFindBy,
|
|
603
|
+
whatsAppFindBy,
|
|
604
|
+
telegramFindBy,
|
|
605
|
+
webhookFindBy,
|
|
606
|
+
notificationRuleFindBy,
|
|
607
|
+
incidentSeverityFindBy,
|
|
608
|
+
alertSeverityFindBy,
|
|
609
|
+
projectFindOneById,
|
|
610
|
+
];
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function totalQueryCount(): number {
|
|
614
|
+
return everySpy().reduce((sum: number, spy: jest.SpyInstance): number => {
|
|
615
|
+
return sum + spy.mock.calls.length;
|
|
616
|
+
}, 0);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Every findBy argument the service passed, for the whole-suite invariants. */
|
|
620
|
+
function everyFindByCall(): Array<FindByCall> {
|
|
621
|
+
const calls: Array<FindByCall> = [];
|
|
622
|
+
|
|
623
|
+
for (const spy of everySpy()) {
|
|
624
|
+
if (spy === policyFindOneById || spy === projectFindOneById) {
|
|
625
|
+
// findOneById takes an id, not a limit; nothing to assert about paging.
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
for (const call of spy.mock.calls) {
|
|
630
|
+
calls.push(call[0] as FindByCall);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return calls;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function setSeverities(data: {
|
|
638
|
+
incident?: Array<IncidentSeverity> | undefined;
|
|
639
|
+
alert?: Array<AlertSeverity> | undefined;
|
|
640
|
+
}): void {
|
|
641
|
+
incidentSeverityFindBy.mockResolvedValue((data.incident || []) as never);
|
|
642
|
+
alertSeverityFindBy.mockResolvedValue((data.alert || []) as never);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** One direct responder on the policy - the simplest shape that pages anyone. */
|
|
646
|
+
function attachDirectly(...userIds: Array<ObjectID>): void {
|
|
647
|
+
escalationUserFindBy.mockResolvedValue(
|
|
648
|
+
userIds.map((userId: ObjectID): OnCallDutyPolicyEscalationRuleUser => {
|
|
649
|
+
return escalationUserRow(userId);
|
|
650
|
+
}) as never,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function policySummary(): Promise<ReadinessSummary> {
|
|
655
|
+
return OnCallReadinessService.getReadinessForPolicy(POLICY_ID, PROJECT_ID);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function onlyUser(): Promise<UserReadiness> {
|
|
659
|
+
const summary: ReadinessSummary = await policySummary();
|
|
660
|
+
|
|
661
|
+
expect(summary.users).toHaveLength(1);
|
|
662
|
+
|
|
663
|
+
return summary.users[0]!;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function cellFor(
|
|
667
|
+
readiness: UserReadiness,
|
|
668
|
+
ruleType: NotificationRuleType,
|
|
669
|
+
severityId: ObjectID,
|
|
670
|
+
): ReadinessCoverageCell {
|
|
671
|
+
const cell: ReadinessCoverageCell | undefined = readiness.coverage.find(
|
|
672
|
+
(candidate: ReadinessCoverageCell): boolean => {
|
|
673
|
+
return (
|
|
674
|
+
candidate.ruleType === ruleType &&
|
|
675
|
+
candidate.severityId?.toString() === severityId.toString()
|
|
676
|
+
);
|
|
677
|
+
},
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
if (!cell) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
`No coverage cell for ${ruleType} / ${severityId.toString()}`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return cell;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function methodTypes(readiness: UserReadiness): Array<string> {
|
|
690
|
+
return readiness.methods.map((method: ReadinessMethod): string => {
|
|
691
|
+
return method.methodType;
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function methodOfType(
|
|
696
|
+
readiness: UserReadiness,
|
|
697
|
+
methodType: ReadinessMethodType,
|
|
698
|
+
): ReadinessMethod {
|
|
699
|
+
const method: ReadinessMethod | undefined = readiness.methods.find(
|
|
700
|
+
(candidate: ReadinessMethod): boolean => {
|
|
701
|
+
return candidate.methodType === methodType;
|
|
702
|
+
},
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
if (!method) {
|
|
706
|
+
throw new Error(`No ${methodType} method on this responder`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return method;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
beforeEach(() => {
|
|
713
|
+
/*
|
|
714
|
+
* The caches are static and live for the whole module, so a summary computed
|
|
715
|
+
* by the previous test would be served to this one and every spy assertion
|
|
716
|
+
* would read zero calls. Clearing here rather than in afterEach means a test
|
|
717
|
+
* that deliberately leaves something cached cannot poison the next file
|
|
718
|
+
* either.
|
|
719
|
+
*/
|
|
720
|
+
OnCallReadinessService.clearCache();
|
|
721
|
+
|
|
722
|
+
userDirectory = [
|
|
723
|
+
makeUser(USER_A_ID, "Ada Lovelace", USER_A_LOGIN_EMAIL),
|
|
724
|
+
makeUser(USER_B_ID, "Grace Hopper", "grace@corp.example.com"),
|
|
725
|
+
makeUser(USER_C_ID, "Katherine Johnson", "katherine@corp.example.com"),
|
|
726
|
+
];
|
|
727
|
+
membershipRows = [
|
|
728
|
+
teamMemberRow(USER_A_ID, TEAM_ID),
|
|
729
|
+
teamMemberRow(USER_B_ID, TEAM_ID),
|
|
730
|
+
teamMemberRow(USER_C_ID, TEAM_ID),
|
|
731
|
+
];
|
|
732
|
+
teamMemberRows = [];
|
|
733
|
+
teamDirectory = [
|
|
734
|
+
teamRow(TEAM_ID, "Platform"),
|
|
735
|
+
teamRow(OTHER_TEAM_ID, "Payments"),
|
|
736
|
+
];
|
|
737
|
+
|
|
738
|
+
policyFindOneById = jest
|
|
739
|
+
.spyOn(OnCallDutyPolicyService, "findOneById")
|
|
740
|
+
.mockResolvedValue(makePolicy(PROJECT_ID) as never);
|
|
741
|
+
|
|
742
|
+
// Default posture: nothing is attached to anything. Each test opts in.
|
|
743
|
+
escalationUserFindBy = jest
|
|
744
|
+
.spyOn(OnCallDutyPolicyEscalationRuleUserService, "findBy")
|
|
745
|
+
.mockResolvedValue([] as never);
|
|
746
|
+
escalationTeamFindBy = jest
|
|
747
|
+
.spyOn(OnCallDutyPolicyEscalationRuleTeamService, "findBy")
|
|
748
|
+
.mockResolvedValue([] as never);
|
|
749
|
+
escalationScheduleFindBy = jest
|
|
750
|
+
.spyOn(OnCallDutyPolicyEscalationRuleScheduleService, "findBy")
|
|
751
|
+
.mockResolvedValue([] as never);
|
|
752
|
+
scheduleLayerUserFindBy = jest
|
|
753
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
754
|
+
.mockResolvedValue([] as never);
|
|
755
|
+
overrideFindBy = jest
|
|
756
|
+
.spyOn(OnCallDutyPolicyUserOverrideService, "findBy")
|
|
757
|
+
.mockResolvedValue([] as never);
|
|
758
|
+
|
|
759
|
+
/*
|
|
760
|
+
* TeamMemberService is asked two structurally different questions - "which of
|
|
761
|
+
* these users is in the project, and in which teams" (userId) and "who is in
|
|
762
|
+
* these teams" (teamId) - so the fake dispatches on the query rather than
|
|
763
|
+
* answering both the same way, which would make the membership guard
|
|
764
|
+
* untestable.
|
|
765
|
+
*
|
|
766
|
+
* The membership answer is FILTERED by the userIds asked for. That is what
|
|
767
|
+
* makes the guard meaningful: a fake that returned somebody else's row for
|
|
768
|
+
* every question would report any user id on earth as a member of the
|
|
769
|
+
* project.
|
|
770
|
+
*/
|
|
771
|
+
teamMemberFindBy = jest
|
|
772
|
+
.spyOn(TeamMemberService, "findBy")
|
|
773
|
+
.mockImplementation((async (
|
|
774
|
+
data: TeamMemberFindByArgument,
|
|
775
|
+
): Promise<Array<TeamMember>> => {
|
|
776
|
+
if (data.query.teamId) {
|
|
777
|
+
return teamMemberRows;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const wanted: Set<string> = new Set<string>(
|
|
781
|
+
includedIds(data.query.userId || new Includes([])),
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
return membershipRows.filter((row: TeamMember): boolean => {
|
|
785
|
+
return wanted.has(row.userId?.toString() || "");
|
|
786
|
+
});
|
|
787
|
+
}) as never);
|
|
788
|
+
|
|
789
|
+
/*
|
|
790
|
+
* Team names, answered out of a directory filtered by the ids asked for. The
|
|
791
|
+
* filter is what makes the "one read for the whole responder set" test mean
|
|
792
|
+
* something: a fake that returned every team regardless would still work while
|
|
793
|
+
* the service asked once per member.
|
|
794
|
+
*/
|
|
795
|
+
teamFindBy = jest.spyOn(TeamService, "findBy").mockImplementation((async (
|
|
796
|
+
data: TeamFindByArgument,
|
|
797
|
+
): Promise<Array<Team>> => {
|
|
798
|
+
const wanted: Set<string> = new Set<string>(
|
|
799
|
+
includedIds(data.query._id || new Includes([])),
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
return teamDirectory.filter((team: Team): boolean => {
|
|
803
|
+
return wanted.has(team.id?.toString() || "");
|
|
804
|
+
});
|
|
805
|
+
}) as never);
|
|
806
|
+
|
|
807
|
+
/*
|
|
808
|
+
* The user lookup answers out of a directory filtered by the Includes the
|
|
809
|
+
* service passes. That is what makes the batching tests meaningful: if the
|
|
810
|
+
* service ever asked per user, this fake would still work but the call count
|
|
811
|
+
* would move, which is precisely the regression being guarded.
|
|
812
|
+
*/
|
|
813
|
+
userFindBy = jest.spyOn(UserService, "findBy").mockImplementation((async (
|
|
814
|
+
data: UserFindByArgument,
|
|
815
|
+
): Promise<Array<User>> => {
|
|
816
|
+
const wanted: Set<string> = new Set<string>(
|
|
817
|
+
includedIds(data.query._id || new Includes([])),
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
return userDirectory.filter((user: User): boolean => {
|
|
821
|
+
return wanted.has(user.id?.toString() || "");
|
|
822
|
+
});
|
|
823
|
+
}) as never);
|
|
824
|
+
|
|
825
|
+
pushFindBy = jest
|
|
826
|
+
.spyOn(UserPushService, "findBy")
|
|
827
|
+
.mockResolvedValue([] as never);
|
|
828
|
+
emailFindBy = jest
|
|
829
|
+
.spyOn(UserEmailService, "findBy")
|
|
830
|
+
.mockResolvedValue([] as never);
|
|
831
|
+
smsFindBy = jest
|
|
832
|
+
.spyOn(UserSmsService, "findBy")
|
|
833
|
+
.mockResolvedValue([] as never);
|
|
834
|
+
callFindBy = jest
|
|
835
|
+
.spyOn(UserCallService, "findBy")
|
|
836
|
+
.mockResolvedValue([] as never);
|
|
837
|
+
whatsAppFindBy = jest
|
|
838
|
+
.spyOn(UserWhatsAppService, "findBy")
|
|
839
|
+
.mockResolvedValue([] as never);
|
|
840
|
+
telegramFindBy = jest
|
|
841
|
+
.spyOn(UserTelegramService, "findBy")
|
|
842
|
+
.mockResolvedValue([] as never);
|
|
843
|
+
webhookFindBy = jest
|
|
844
|
+
.spyOn(UserWebhookService, "findBy")
|
|
845
|
+
.mockResolvedValue([] as never);
|
|
846
|
+
|
|
847
|
+
notificationRuleFindBy = jest
|
|
848
|
+
.spyOn(UserNotificationRuleService, "findBy")
|
|
849
|
+
.mockResolvedValue([] as never);
|
|
850
|
+
|
|
851
|
+
incidentSeverityFindBy = jest
|
|
852
|
+
.spyOn(IncidentSeverityService, "findBy")
|
|
853
|
+
.mockResolvedValue([] as never);
|
|
854
|
+
alertSeverityFindBy = jest
|
|
855
|
+
.spyOn(AlertSeverityService, "findBy")
|
|
856
|
+
.mockResolvedValue([] as never);
|
|
857
|
+
|
|
858
|
+
projectFindOneById = jest
|
|
859
|
+
.spyOn(ProjectService, "findOneById")
|
|
860
|
+
.mockResolvedValue(makeProject() as never);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
afterEach(() => {
|
|
864
|
+
jest.restoreAllMocks();
|
|
865
|
+
OnCallReadinessService.clearCache();
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
/*
|
|
869
|
+
* ---------------------------------------------------------------------------
|
|
870
|
+
* (A) maskIdentifier as a pure function.
|
|
871
|
+
*
|
|
872
|
+
* Masking is the one rule in this service that must never be got wrong even
|
|
873
|
+
* slightly, and it is exported as a free function precisely so it can be
|
|
874
|
+
* exercised at this granularity. The interesting cases are all the DEGENERATE
|
|
875
|
+
* ones - a one-character handle, a four-digit number, a string with no "@" that
|
|
876
|
+
* arrived in an email column - because those are where a mask stops hiding
|
|
877
|
+
* anything and nobody notices.
|
|
878
|
+
* ---------------------------------------------------------------------------
|
|
879
|
+
*/
|
|
880
|
+
describe("maskIdentifier", () => {
|
|
881
|
+
test("the shared mask constant is three bullets, so a look-alike typo is a test failure", () => {
|
|
882
|
+
expect(IDENTIFIER_MASK).toBe("•••");
|
|
883
|
+
expect(IDENTIFIER_MASK).toHaveLength(3);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
describe("email", () => {
|
|
887
|
+
test("keeps one leading character and the whole domain", () => {
|
|
888
|
+
expect(
|
|
889
|
+
maskIdentifier("jane@example.com", MaskedIdentifierKind.Email),
|
|
890
|
+
).toBe(`j${IDENTIFIER_MASK}@example.com`);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test("a plus-addressed local part is hidden past its first character", () => {
|
|
894
|
+
const masked: string = maskIdentifier(
|
|
895
|
+
"ops+pager-escalation@example.com",
|
|
896
|
+
MaskedIdentifierKind.Email,
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
expect(masked).toBe(`o${IDENTIFIER_MASK}@example.com`);
|
|
900
|
+
expect(masked).not.toContain("pager");
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test("a single-character local part reveals nothing extra", () => {
|
|
904
|
+
expect(maskIdentifier("a@b.co", MaskedIdentifierKind.Email)).toBe(
|
|
905
|
+
`a${IDENTIFIER_MASK}@b.co`,
|
|
906
|
+
);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
test("splits on the LAST @, so a quoted local part cannot smuggle the address through", () => {
|
|
910
|
+
expect(
|
|
911
|
+
maskIdentifier('"weird@local"@example.com', MaskedIdentifierKind.Email),
|
|
912
|
+
).toBe(`"${IDENTIFIER_MASK}@example.com`);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("a value with no @ falls through to the stricter handle rule rather than being guessed at", () => {
|
|
916
|
+
expect(maskIdentifier("notanemail", MaskedIdentifierKind.Email)).toBe(
|
|
917
|
+
`no${IDENTIFIER_MASK}`,
|
|
918
|
+
);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("a leading @ with no local part is a handle, not a domain to publish", () => {
|
|
922
|
+
expect(maskIdentifier("@example.com", MaskedIdentifierKind.Email)).toBe(
|
|
923
|
+
`@ex${IDENTIFIER_MASK}`,
|
|
924
|
+
);
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
describe("phone", () => {
|
|
929
|
+
test("keeps the country code and the last four digits", () => {
|
|
930
|
+
expect(maskIdentifier("+14155554821", MaskedIdentifierKind.Phone)).toBe(
|
|
931
|
+
`+1 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`,
|
|
932
|
+
);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test("a two-digit country code survives, because the rule is 'everything before the last ten'", () => {
|
|
936
|
+
expect(maskIdentifier("+442071838750", MaskedIdentifierKind.Phone)).toBe(
|
|
937
|
+
`+44 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 8750`,
|
|
938
|
+
);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test("formatting characters are stripped before the digits are counted", () => {
|
|
942
|
+
expect(
|
|
943
|
+
maskIdentifier("+1 (415) 555-4821", MaskedIdentifierKind.Phone),
|
|
944
|
+
).toBe(`+1 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test("a country whose national plan is not ten digits loses a country-code digit, and hides MORE as a result", () => {
|
|
948
|
+
/*
|
|
949
|
+
* +61 2 9374 4000 is eleven digits, and the rule is "everything before
|
|
950
|
+
* the last ten is the country code", so the split lands after "6" rather
|
|
951
|
+
* than after "61". That is the heuristic behaving as documented rather
|
|
952
|
+
* than a defect: it is a parse-free rule, and the digit it gets wrong is
|
|
953
|
+
* one it HIDES. Correctness here is measured in what is concealed, and
|
|
954
|
+
* concealing an extra digit of a country code errs in the safe
|
|
955
|
+
* direction - so this is pinned deliberately, to keep anyone from
|
|
956
|
+
* "fixing" it into a real libphonenumber parse that could round the other
|
|
957
|
+
* way.
|
|
958
|
+
*/
|
|
959
|
+
const masked: string = maskIdentifier(
|
|
960
|
+
"+61293744000",
|
|
961
|
+
MaskedIdentifierKind.Phone,
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
expect(masked).toBe(`+6 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4000`);
|
|
965
|
+
expect(masked).not.toContain("9374");
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test("a bare national number produces no country code prefix", () => {
|
|
969
|
+
const masked: string = maskIdentifier(
|
|
970
|
+
"4155554821",
|
|
971
|
+
MaskedIdentifierKind.Phone,
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
expect(masked).toBe(`${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`);
|
|
975
|
+
expect(masked.startsWith("+")).toBe(false);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test("fewer than four digits is masked entirely - four digits of a three digit number is the number", () => {
|
|
979
|
+
expect(maskIdentifier("123", MaskedIdentifierKind.Phone)).toBe(
|
|
980
|
+
IDENTIFIER_MASK,
|
|
981
|
+
);
|
|
982
|
+
expect(maskIdentifier("+1", MaskedIdentifierKind.Phone)).toBe(
|
|
983
|
+
IDENTIFIER_MASK,
|
|
984
|
+
);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test("EXACTLY four digits is masked entirely - the last four of a four-digit value is the value", () => {
|
|
988
|
+
/*
|
|
989
|
+
* The boundary, and the one the original `< 4` got wrong. A four-digit
|
|
990
|
+
* value passed the guard and then had its "last four" revealed, which is
|
|
991
|
+
* all of it: the function returned the identifier in full while looking,
|
|
992
|
+
* on screen and in review, exactly like a mask. Anything at or below the
|
|
993
|
+
* number of digits the mask keeps has to be hidden completely, because a
|
|
994
|
+
* value we cannot hide half of is a value we do not show.
|
|
995
|
+
*/
|
|
996
|
+
expect(maskIdentifier("4821", MaskedIdentifierKind.Phone)).toBe(
|
|
997
|
+
IDENTIFIER_MASK,
|
|
998
|
+
);
|
|
999
|
+
// Formatting is stripped before the digits are counted, so this is four.
|
|
1000
|
+
expect(maskIdentifier("(48) 21", MaskedIdentifierKind.Phone)).toBe(
|
|
1001
|
+
IDENTIFIER_MASK,
|
|
1002
|
+
);
|
|
1003
|
+
expect(maskIdentifier("0000", MaskedIdentifierKind.Phone)).toBe(
|
|
1004
|
+
IDENTIFIER_MASK,
|
|
1005
|
+
);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
test("five digits is the shortest value that reveals anything, and reveals only four of them", () => {
|
|
1009
|
+
const masked: string = maskIdentifier(
|
|
1010
|
+
"54821",
|
|
1011
|
+
MaskedIdentifierKind.Phone,
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
expect(masked).toBe(`${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`);
|
|
1015
|
+
expect(masked.startsWith("5")).toBe(false);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test("a value with no digits at all is masked entirely", () => {
|
|
1019
|
+
expect(maskIdentifier("not-a-phone", MaskedIdentifierKind.Phone)).toBe(
|
|
1020
|
+
IDENTIFIER_MASK,
|
|
1021
|
+
);
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
describe("handle", () => {
|
|
1026
|
+
test("keeps the @ sigil and two characters", () => {
|
|
1027
|
+
expect(maskIdentifier("@jamesbond", MaskedIdentifierKind.Handle)).toBe(
|
|
1028
|
+
`@ja${IDENTIFIER_MASK}`,
|
|
1029
|
+
);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test("a handle without a sigil does not grow one", () => {
|
|
1033
|
+
expect(maskIdentifier("jamesbond", MaskedIdentifierKind.Handle)).toBe(
|
|
1034
|
+
`ja${IDENTIFIER_MASK}`,
|
|
1035
|
+
);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("a one-character handle reveals one character, not a padded lie", () => {
|
|
1039
|
+
expect(maskIdentifier("j", MaskedIdentifierKind.Handle)).toBe(
|
|
1040
|
+
`j${IDENTIFIER_MASK}`,
|
|
1041
|
+
);
|
|
1042
|
+
expect(maskIdentifier("@j", MaskedIdentifierKind.Handle)).toBe(
|
|
1043
|
+
`@j${IDENTIFIER_MASK}`,
|
|
1044
|
+
);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
test("a device name is trimmed before it is cut, so leading space is not spent as a revealed character", () => {
|
|
1048
|
+
expect(
|
|
1049
|
+
maskIdentifier(" Ada iPhone ", MaskedIdentifierKind.Handle),
|
|
1050
|
+
).toBe(`Ad${IDENTIFIER_MASK}`);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
test("a long device name keeps nothing but its first two characters", () => {
|
|
1054
|
+
const masked: string = maskIdentifier(
|
|
1055
|
+
RAW_PUSH_DEVICE,
|
|
1056
|
+
MaskedIdentifierKind.Handle,
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
expect(masked).toBe(`Ad${IDENTIFIER_MASK}`);
|
|
1060
|
+
expect(masked).not.toContain("iPhone");
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
describe("absent values", () => {
|
|
1065
|
+
test.each([
|
|
1066
|
+
["undefined", undefined],
|
|
1067
|
+
["null", null],
|
|
1068
|
+
["empty", ""],
|
|
1069
|
+
["whitespace", " "],
|
|
1070
|
+
])(
|
|
1071
|
+
"%s masks to the bare bullet rather than a blank cell that reads as 'no method'",
|
|
1072
|
+
(_label: string, value: string | undefined | null) => {
|
|
1073
|
+
expect(maskIdentifier(value, MaskedIdentifierKind.Email)).toBe(
|
|
1074
|
+
IDENTIFIER_MASK,
|
|
1075
|
+
);
|
|
1076
|
+
expect(maskIdentifier(value, MaskedIdentifierKind.Phone)).toBe(
|
|
1077
|
+
IDENTIFIER_MASK,
|
|
1078
|
+
);
|
|
1079
|
+
expect(maskIdentifier(value, MaskedIdentifierKind.Handle)).toBe(
|
|
1080
|
+
IDENTIFIER_MASK,
|
|
1081
|
+
);
|
|
1082
|
+
},
|
|
1083
|
+
);
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
/*
|
|
1088
|
+
* ---------------------------------------------------------------------------
|
|
1089
|
+
* (B) Responder resolution.
|
|
1090
|
+
*
|
|
1091
|
+
* TeamComplianceService was TEAM-SCOPED, which meant the three ways a responder
|
|
1092
|
+
* most commonly gets paged - attached directly, reached through a schedule
|
|
1093
|
+
* layer, substituted in by an override - were invisible to it. Each of those is
|
|
1094
|
+
* a separate test here, and each would have been a silent false green before.
|
|
1095
|
+
* ---------------------------------------------------------------------------
|
|
1096
|
+
*/
|
|
1097
|
+
describe("responder resolution", () => {
|
|
1098
|
+
test("a user attached DIRECTLY to an escalation rule is a responder (team-scoping defect)", async () => {
|
|
1099
|
+
attachDirectly(USER_A_ID);
|
|
1100
|
+
|
|
1101
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1102
|
+
|
|
1103
|
+
expect(readiness.userId.toString()).toBe(USER_A_ID.toString());
|
|
1104
|
+
expect(readiness.reachedVia).toEqual([ResponderSource.Direct]);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
test("a user reached only through a TEAM on an escalation rule is a responder", async () => {
|
|
1108
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1109
|
+
escalationTeamRow(TEAM_ID),
|
|
1110
|
+
] as never);
|
|
1111
|
+
teamMemberRows = [teamMemberRow(USER_B_ID)];
|
|
1112
|
+
|
|
1113
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1114
|
+
|
|
1115
|
+
expect(readiness.userId.toString()).toBe(USER_B_ID.toString());
|
|
1116
|
+
expect(readiness.reachedVia).toEqual([ResponderSource.Team]);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
test("a user reached only through a SCHEDULE LAYER is a responder (team-scoping defect)", async () => {
|
|
1120
|
+
escalationScheduleFindBy.mockResolvedValue([
|
|
1121
|
+
escalationScheduleRow(SCHEDULE_ID),
|
|
1122
|
+
] as never);
|
|
1123
|
+
scheduleLayerUserFindBy.mockResolvedValue([
|
|
1124
|
+
layerUserRow(USER_C_ID),
|
|
1125
|
+
] as never);
|
|
1126
|
+
|
|
1127
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1128
|
+
|
|
1129
|
+
expect(readiness.userId.toString()).toBe(USER_C_ID.toString());
|
|
1130
|
+
expect(readiness.reachedVia).toEqual([ResponderSource.Schedule]);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
test("a user an OVERRIDE routes pages to is a responder, even attached to nothing else", async () => {
|
|
1134
|
+
overrideFindBy.mockResolvedValue([overrideRow(USER_C_ID)] as never);
|
|
1135
|
+
|
|
1136
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1137
|
+
|
|
1138
|
+
expect(readiness.userId.toString()).toBe(USER_C_ID.toString());
|
|
1139
|
+
expect(readiness.reachedVia).toEqual([ResponderSource.Override]);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
test("a user reached two ways appears ONCE and carries BOTH sources", async () => {
|
|
1143
|
+
attachDirectly(USER_A_ID);
|
|
1144
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1145
|
+
escalationTeamRow(TEAM_ID),
|
|
1146
|
+
] as never);
|
|
1147
|
+
teamMemberRows = [teamMemberRow(USER_A_ID)];
|
|
1148
|
+
|
|
1149
|
+
const summary: ReadinessSummary = await policySummary();
|
|
1150
|
+
|
|
1151
|
+
expect(summary.users).toHaveLength(1);
|
|
1152
|
+
expect(summary.users[0]!.reachedVia).toEqual([
|
|
1153
|
+
ResponderSource.Direct,
|
|
1154
|
+
ResponderSource.Team,
|
|
1155
|
+
]);
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
test("a user reached all four ways carries all four sources in canonical order", async () => {
|
|
1159
|
+
attachDirectly(USER_A_ID);
|
|
1160
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1161
|
+
escalationTeamRow(TEAM_ID),
|
|
1162
|
+
] as never);
|
|
1163
|
+
teamMemberRows = [teamMemberRow(USER_A_ID)];
|
|
1164
|
+
escalationScheduleFindBy.mockResolvedValue([
|
|
1165
|
+
escalationScheduleRow(SCHEDULE_ID),
|
|
1166
|
+
] as never);
|
|
1167
|
+
scheduleLayerUserFindBy.mockResolvedValue([
|
|
1168
|
+
layerUserRow(USER_A_ID),
|
|
1169
|
+
] as never);
|
|
1170
|
+
overrideFindBy.mockResolvedValue([overrideRow(USER_A_ID)] as never);
|
|
1171
|
+
|
|
1172
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1173
|
+
|
|
1174
|
+
expect(readiness.reachedVia).toEqual([
|
|
1175
|
+
ResponderSource.Direct,
|
|
1176
|
+
ResponderSource.Team,
|
|
1177
|
+
ResponderSource.Schedule,
|
|
1178
|
+
ResponderSource.Override,
|
|
1179
|
+
]);
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
test("a user attached twice through the same door is still one responder", async () => {
|
|
1183
|
+
escalationUserFindBy.mockResolvedValue([
|
|
1184
|
+
escalationUserRow(USER_A_ID),
|
|
1185
|
+
escalationUserRow(USER_A_ID),
|
|
1186
|
+
] as never);
|
|
1187
|
+
|
|
1188
|
+
const summary: ReadinessSummary = await policySummary();
|
|
1189
|
+
|
|
1190
|
+
expect(summary.users).toHaveLength(1);
|
|
1191
|
+
expect(summary.users[0]!.reachedVia).toEqual([ResponderSource.Direct]);
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("an escalation row with no user, and an override routing to nobody, are ignored rather than crashing", async () => {
|
|
1195
|
+
escalationUserFindBy.mockResolvedValue([
|
|
1196
|
+
escalationUserRow(undefined),
|
|
1197
|
+
] as never);
|
|
1198
|
+
overrideFindBy.mockResolvedValue([overrideRow(undefined)] as never);
|
|
1199
|
+
|
|
1200
|
+
const summary: ReadinessSummary = await policySummary();
|
|
1201
|
+
|
|
1202
|
+
expect(summary.users).toEqual([]);
|
|
1203
|
+
expect(summary.readyCount).toBe(0);
|
|
1204
|
+
expect(summary.notReachableCount).toBe(0);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("every team on the policy is expanded by ONE query, with duplicate team ids collapsed", async () => {
|
|
1208
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1209
|
+
escalationTeamRow(TEAM_ID),
|
|
1210
|
+
escalationTeamRow(OTHER_TEAM_ID),
|
|
1211
|
+
escalationTeamRow(TEAM_ID),
|
|
1212
|
+
] as never);
|
|
1213
|
+
teamMemberRows = [teamMemberRow(USER_A_ID), teamMemberRow(USER_B_ID)];
|
|
1214
|
+
|
|
1215
|
+
const summary: ReadinessSummary = await policySummary();
|
|
1216
|
+
|
|
1217
|
+
expect(summary.users).toHaveLength(2);
|
|
1218
|
+
|
|
1219
|
+
const memberCalls: Array<FindByCall> = teamMemberFindBy.mock.calls.map(
|
|
1220
|
+
(call: Array<unknown>): FindByCall => {
|
|
1221
|
+
return call[0] as FindByCall;
|
|
1222
|
+
},
|
|
1223
|
+
);
|
|
1224
|
+
|
|
1225
|
+
expect(memberCalls).toHaveLength(1);
|
|
1226
|
+
expect(includedIds(memberCalls[0]!.query["teamId"])).toEqual([
|
|
1227
|
+
TEAM_ID.toString(),
|
|
1228
|
+
OTHER_TEAM_ID.toString(),
|
|
1229
|
+
]);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test("team members are NOT filtered on hasAcceptedInvitation, because the runtime pages them anyway", async () => {
|
|
1233
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1234
|
+
escalationTeamRow(TEAM_ID),
|
|
1235
|
+
] as never);
|
|
1236
|
+
teamMemberRows = [teamMemberRow(USER_A_ID)];
|
|
1237
|
+
|
|
1238
|
+
await policySummary();
|
|
1239
|
+
|
|
1240
|
+
const query: Record<string, unknown> = firstCall(teamMemberFindBy).query;
|
|
1241
|
+
|
|
1242
|
+
expect(query["hasAcceptedInvitation"]).toBeUndefined();
|
|
1243
|
+
expect(query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
test("no teams on the policy means the member expansion is never asked for", async () => {
|
|
1247
|
+
attachDirectly(USER_A_ID);
|
|
1248
|
+
|
|
1249
|
+
await policySummary();
|
|
1250
|
+
|
|
1251
|
+
expect(teamMemberFindBy).not.toHaveBeenCalled();
|
|
1252
|
+
expect(scheduleLayerUserFindBy).not.toHaveBeenCalled();
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
test("the policy scope filters all three escalation reads by policy id", async () => {
|
|
1256
|
+
attachDirectly(USER_A_ID);
|
|
1257
|
+
|
|
1258
|
+
await policySummary();
|
|
1259
|
+
|
|
1260
|
+
for (const spy of [
|
|
1261
|
+
escalationUserFindBy,
|
|
1262
|
+
escalationTeamFindBy,
|
|
1263
|
+
escalationScheduleFindBy,
|
|
1264
|
+
]) {
|
|
1265
|
+
const query: Record<string, unknown> = firstCall(spy).query;
|
|
1266
|
+
|
|
1267
|
+
expect(query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
|
|
1268
|
+
expect(query["onCallDutyPolicyId"]?.toString()).toBe(
|
|
1269
|
+
POLICY_ID.toString(),
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
test("the project scope drops the policy filter entirely, so a user on ANY policy is included", async () => {
|
|
1275
|
+
attachDirectly(USER_A_ID);
|
|
1276
|
+
|
|
1277
|
+
const summary: ReadinessSummary =
|
|
1278
|
+
await OnCallReadinessService.getReadinessForProject(PROJECT_ID);
|
|
1279
|
+
|
|
1280
|
+
expect(summary.onCallDutyPolicyId).toBeUndefined();
|
|
1281
|
+
expect(summary.projectId.toString()).toBe(PROJECT_ID.toString());
|
|
1282
|
+
|
|
1283
|
+
for (const spy of [
|
|
1284
|
+
escalationUserFindBy,
|
|
1285
|
+
escalationTeamFindBy,
|
|
1286
|
+
escalationScheduleFindBy,
|
|
1287
|
+
overrideFindBy,
|
|
1288
|
+
]) {
|
|
1289
|
+
expect(firstCall(spy).query["onCallDutyPolicyId"]).toBeUndefined();
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test("overrides are filtered to those that have not ENDED - an expired override pages nobody", async () => {
|
|
1294
|
+
attachDirectly(USER_A_ID);
|
|
1295
|
+
|
|
1296
|
+
await policySummary();
|
|
1297
|
+
|
|
1298
|
+
const endsAt: unknown = firstCall(overrideFindBy).query["endsAt"];
|
|
1299
|
+
|
|
1300
|
+
expect(endsAt).toBeInstanceOf(FindOperator);
|
|
1301
|
+
expect(
|
|
1302
|
+
(endsAt as FindOperator<unknown>).getSql!("override.endsAt"),
|
|
1303
|
+
).toContain(">=");
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
test("a GLOBAL override with a null policy id still counts against a specific policy", async () => {
|
|
1307
|
+
attachDirectly(USER_A_ID);
|
|
1308
|
+
|
|
1309
|
+
await policySummary();
|
|
1310
|
+
|
|
1311
|
+
const policyFilter: unknown =
|
|
1312
|
+
firstCall(overrideFindBy).query["onCallDutyPolicyId"];
|
|
1313
|
+
|
|
1314
|
+
/*
|
|
1315
|
+
* equalToOrNull, not equality. Filtering on equality alone would drop every
|
|
1316
|
+
* global override, which is the most commonly configured kind - and the
|
|
1317
|
+
* substitute they route to is the responder most likely to be silently
|
|
1318
|
+
* unreachable.
|
|
1319
|
+
*/
|
|
1320
|
+
expect(policyFilter).toBeInstanceOf(FindOperator);
|
|
1321
|
+
expect(
|
|
1322
|
+
(policyFilter as FindOperator<unknown>).getSql!(
|
|
1323
|
+
"override.onCallDutyPolicyId",
|
|
1324
|
+
),
|
|
1325
|
+
).toContain("IS NULL");
|
|
1326
|
+
expect(
|
|
1327
|
+
Object.values(
|
|
1328
|
+
(policyFilter as FindOperator<unknown>).objectLiteralParameters || {},
|
|
1329
|
+
),
|
|
1330
|
+
).toContain(POLICY_ID.toString());
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
test("a responder with no User row is dropped rather than reported as a nameless gap", async () => {
|
|
1334
|
+
attachDirectly(USER_A_ID);
|
|
1335
|
+
userDirectory = [];
|
|
1336
|
+
|
|
1337
|
+
const summary: ReadinessSummary = await policySummary();
|
|
1338
|
+
|
|
1339
|
+
expect(summary.users).toEqual([]);
|
|
1340
|
+
});
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
/*
|
|
1344
|
+
* ---------------------------------------------------------------------------
|
|
1345
|
+
* (B2) WHICH teams page a responder.
|
|
1346
|
+
*
|
|
1347
|
+
* `reachedVia` has always said THAT a team was involved. It could not say which
|
|
1348
|
+
* one, because the resolution pass read exactly the membership rows that would
|
|
1349
|
+
* answer it and then dropped the team id on the floor - so the readiness table
|
|
1350
|
+
* could show a "Team" chip and could not be filtered down to a team.
|
|
1351
|
+
*
|
|
1352
|
+
* The semantics pinned here are narrower than "teams this person belongs to",
|
|
1353
|
+
* deliberately: a team with no escalation rule attached to it pages nobody, and
|
|
1354
|
+
* naming it would let an admin filter the table to that team and read a clean
|
|
1355
|
+
* answer about people it cannot reach. Every team named on a responder is a team
|
|
1356
|
+
* that both (a) they belong to and (b) is attached to an escalation rule in the
|
|
1357
|
+
* scope being computed - which is exactly the set that puts Team in reachedVia.
|
|
1358
|
+
* ---------------------------------------------------------------------------
|
|
1359
|
+
*/
|
|
1360
|
+
describe("the teams that page a responder", () => {
|
|
1361
|
+
test("a responder reached through a team carries that team, named", async () => {
|
|
1362
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1363
|
+
escalationTeamRow(TEAM_ID),
|
|
1364
|
+
] as never);
|
|
1365
|
+
teamMemberRows = [teamMemberRow(USER_B_ID, TEAM_ID)];
|
|
1366
|
+
|
|
1367
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1368
|
+
|
|
1369
|
+
expect(readiness.reachedVia).toEqual([ResponderSource.Team]);
|
|
1370
|
+
expect(readiness.teams).toHaveLength(1);
|
|
1371
|
+
expect(readiness.teams[0]!.name).toBe("Platform");
|
|
1372
|
+
expect(readiness.teams[0]!._id.toString()).toBe(TEAM_ID.toString());
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
/*
|
|
1376
|
+
* A responder attached directly is reached without any team being involved, so
|
|
1377
|
+
* an empty list is the honest answer. Filling it with their project teams
|
|
1378
|
+
* would put them under a team filter they are not answerable to.
|
|
1379
|
+
*/
|
|
1380
|
+
test("a responder reached directly carries no teams at all", async () => {
|
|
1381
|
+
attachDirectly(USER_A_ID);
|
|
1382
|
+
|
|
1383
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1384
|
+
|
|
1385
|
+
expect(readiness.reachedVia).toEqual([ResponderSource.Direct]);
|
|
1386
|
+
expect(readiness.teams).toEqual([]);
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
test("a responder reached only by a schedule carries no teams", async () => {
|
|
1390
|
+
escalationScheduleFindBy.mockResolvedValue([
|
|
1391
|
+
escalationScheduleRow(SCHEDULE_ID),
|
|
1392
|
+
] as never);
|
|
1393
|
+
scheduleLayerUserFindBy.mockResolvedValue([
|
|
1394
|
+
layerUserRow(USER_C_ID),
|
|
1395
|
+
] as never);
|
|
1396
|
+
|
|
1397
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1398
|
+
|
|
1399
|
+
expect(readiness.teams).toEqual([]);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
/*
|
|
1403
|
+
* Both teams, because both page them. Removing them from one does not stop the
|
|
1404
|
+
* other, which is the same reasoning reachedVia carries every source rather
|
|
1405
|
+
* than the first one found - and it is what lets one responder answer a filter
|
|
1406
|
+
* for either team.
|
|
1407
|
+
*/
|
|
1408
|
+
test("a responder on two attached teams carries both", async () => {
|
|
1409
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1410
|
+
escalationTeamRow(TEAM_ID),
|
|
1411
|
+
escalationTeamRow(OTHER_TEAM_ID),
|
|
1412
|
+
] as never);
|
|
1413
|
+
teamMemberRows = [
|
|
1414
|
+
teamMemberRow(USER_A_ID, TEAM_ID),
|
|
1415
|
+
teamMemberRow(USER_A_ID, OTHER_TEAM_ID),
|
|
1416
|
+
];
|
|
1417
|
+
|
|
1418
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1419
|
+
|
|
1420
|
+
expect(
|
|
1421
|
+
readiness.teams.map((team: ReadinessTeam): string => {
|
|
1422
|
+
return team.name;
|
|
1423
|
+
}),
|
|
1424
|
+
).toEqual(["Payments", "Platform"]);
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
/*
|
|
1428
|
+
* Sorted by NAME rather than by whichever membership row the database returned
|
|
1429
|
+
* first, so the column and the filter chip read the same way for every
|
|
1430
|
+
* responder and do not reshuffle between requests.
|
|
1431
|
+
*/
|
|
1432
|
+
test("the teams are in name order, not in row order", async () => {
|
|
1433
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1434
|
+
escalationTeamRow(OTHER_TEAM_ID),
|
|
1435
|
+
escalationTeamRow(TEAM_ID),
|
|
1436
|
+
] as never);
|
|
1437
|
+
teamMemberRows = [
|
|
1438
|
+
teamMemberRow(USER_A_ID, OTHER_TEAM_ID),
|
|
1439
|
+
teamMemberRow(USER_A_ID, TEAM_ID),
|
|
1440
|
+
];
|
|
1441
|
+
|
|
1442
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1443
|
+
|
|
1444
|
+
expect(
|
|
1445
|
+
readiness.teams.map((team: ReadinessTeam): string => {
|
|
1446
|
+
return team.name;
|
|
1447
|
+
}),
|
|
1448
|
+
).toEqual(["Payments", "Platform"]);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test("the same team twice through two rows is carried once", async () => {
|
|
1452
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1453
|
+
escalationTeamRow(TEAM_ID),
|
|
1454
|
+
] as never);
|
|
1455
|
+
teamMemberRows = [
|
|
1456
|
+
teamMemberRow(USER_A_ID, TEAM_ID),
|
|
1457
|
+
teamMemberRow(USER_A_ID, TEAM_ID),
|
|
1458
|
+
];
|
|
1459
|
+
|
|
1460
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1461
|
+
|
|
1462
|
+
expect(readiness.teams).toHaveLength(1);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
/*
|
|
1466
|
+
* ONE read for every team named anywhere in the responder set. A team on an
|
|
1467
|
+
* escalation rule is shared by every one of its members, so a per-member read
|
|
1468
|
+
* is the N+1 that made the report this service replaced unusable on a project
|
|
1469
|
+
* of any size.
|
|
1470
|
+
*/
|
|
1471
|
+
test("team names are read once for the whole responder set, not once per member", async () => {
|
|
1472
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1473
|
+
escalationTeamRow(TEAM_ID),
|
|
1474
|
+
escalationTeamRow(OTHER_TEAM_ID),
|
|
1475
|
+
] as never);
|
|
1476
|
+
teamMemberRows = [
|
|
1477
|
+
teamMemberRow(USER_A_ID, TEAM_ID),
|
|
1478
|
+
teamMemberRow(USER_B_ID, TEAM_ID),
|
|
1479
|
+
teamMemberRow(USER_C_ID, OTHER_TEAM_ID),
|
|
1480
|
+
];
|
|
1481
|
+
|
|
1482
|
+
const summary: ReadinessSummary = await policySummary();
|
|
1483
|
+
|
|
1484
|
+
expect(summary.users).toHaveLength(3);
|
|
1485
|
+
expect(teamFindBy.mock.calls).toHaveLength(1);
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
/*
|
|
1489
|
+
* Nobody is reached through a team, so there is nothing to name. A read issued
|
|
1490
|
+
* anyway would be a query per page load buying nothing.
|
|
1491
|
+
*/
|
|
1492
|
+
test("no team read at all when no team pages anybody", async () => {
|
|
1493
|
+
attachDirectly(USER_A_ID);
|
|
1494
|
+
|
|
1495
|
+
await policySummary();
|
|
1496
|
+
|
|
1497
|
+
expect(teamFindBy).not.toHaveBeenCalled();
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
/*
|
|
1501
|
+
* A team whose row did not come back is DROPPED rather than rendered as a bare
|
|
1502
|
+
* id. This list is what builds the readiness table's team filter, and an option
|
|
1503
|
+
* nobody can read is an option nobody can choose on purpose - whereas a chip
|
|
1504
|
+
* showing a uuid is one an admin might act on.
|
|
1505
|
+
*/
|
|
1506
|
+
test("a team whose row did not come back is dropped rather than shown nameless", async () => {
|
|
1507
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1508
|
+
escalationTeamRow(TEAM_ID),
|
|
1509
|
+
escalationTeamRow(OTHER_TEAM_ID),
|
|
1510
|
+
] as never);
|
|
1511
|
+
teamMemberRows = [
|
|
1512
|
+
teamMemberRow(USER_A_ID, TEAM_ID),
|
|
1513
|
+
teamMemberRow(USER_A_ID, OTHER_TEAM_ID),
|
|
1514
|
+
];
|
|
1515
|
+
teamDirectory = [teamRow(TEAM_ID, "Platform")];
|
|
1516
|
+
|
|
1517
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1518
|
+
|
|
1519
|
+
expect(
|
|
1520
|
+
readiness.teams.map((team: ReadinessTeam): string => {
|
|
1521
|
+
return team.name;
|
|
1522
|
+
}),
|
|
1523
|
+
).toEqual(["Platform"]);
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
test("the team read is scoped to the project and to the teams that page somebody", async () => {
|
|
1527
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1528
|
+
escalationTeamRow(TEAM_ID),
|
|
1529
|
+
] as never);
|
|
1530
|
+
teamMemberRows = [teamMemberRow(USER_A_ID, TEAM_ID)];
|
|
1531
|
+
|
|
1532
|
+
await policySummary();
|
|
1533
|
+
|
|
1534
|
+
const query: Record<string, unknown> = firstCall(teamFindBy).query;
|
|
1535
|
+
|
|
1536
|
+
expect(query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
|
|
1537
|
+
expect(includedIds(query["_id"] as Includes)).toEqual([TEAM_ID.toString()]);
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
/*
|
|
1541
|
+
* The per-USER entry point resolves responders the other way round - from the
|
|
1542
|
+
* user's own memberships inwards - and it has to reach the same answer. Two
|
|
1543
|
+
* paths that disagree would show a responder a different team list depending on
|
|
1544
|
+
* whether an admin opened the readiness table or that responder's own card,
|
|
1545
|
+
* and each answer would sit in its own cache for a minute.
|
|
1546
|
+
*/
|
|
1547
|
+
test("the per-user path names the same teams as the project path", async () => {
|
|
1548
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1549
|
+
escalationTeamRow(TEAM_ID),
|
|
1550
|
+
] as never);
|
|
1551
|
+
membershipRows = [teamMemberRow(USER_A_ID, TEAM_ID)];
|
|
1552
|
+
|
|
1553
|
+
const readiness: UserReadiness | null =
|
|
1554
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
1555
|
+
|
|
1556
|
+
expect(readiness).not.toBeNull();
|
|
1557
|
+
expect(readiness!.reachedVia).toEqual([ResponderSource.Team]);
|
|
1558
|
+
expect(
|
|
1559
|
+
readiness!.teams.map((team: ReadinessTeam): string => {
|
|
1560
|
+
return team.name;
|
|
1561
|
+
}),
|
|
1562
|
+
).toEqual(["Platform"]);
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
/*
|
|
1566
|
+
* A team the user belongs to but which is attached to no escalation rule pages
|
|
1567
|
+
* nobody, so it must not appear - on either path. This is the assertion that
|
|
1568
|
+
* makes "teams that page you" different from "teams you are in".
|
|
1569
|
+
*/
|
|
1570
|
+
test("the per-user path leaves out a team that is attached to nothing", async () => {
|
|
1571
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
1572
|
+
escalationTeamRow(TEAM_ID),
|
|
1573
|
+
] as never);
|
|
1574
|
+
membershipRows = [
|
|
1575
|
+
teamMemberRow(USER_A_ID, TEAM_ID),
|
|
1576
|
+
teamMemberRow(USER_A_ID, OTHER_TEAM_ID),
|
|
1577
|
+
];
|
|
1578
|
+
|
|
1579
|
+
const readiness: UserReadiness | null =
|
|
1580
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
1581
|
+
|
|
1582
|
+
expect(
|
|
1583
|
+
readiness!.teams.map((team: ReadinessTeam): string => {
|
|
1584
|
+
return team.name;
|
|
1585
|
+
}),
|
|
1586
|
+
).toEqual(["Platform"]);
|
|
1587
|
+
});
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
/*
|
|
1591
|
+
* ---------------------------------------------------------------------------
|
|
1592
|
+
* (C) The status calculation, at every boundary.
|
|
1593
|
+
* ---------------------------------------------------------------------------
|
|
1594
|
+
*/
|
|
1595
|
+
describe("status", () => {
|
|
1596
|
+
beforeEach(() => {
|
|
1597
|
+
attachDirectly(USER_A_ID);
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
test("zero notification methods is NotReachable, and says so before it says anything else", async () => {
|
|
1601
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1602
|
+
|
|
1603
|
+
expect(readiness.status).toBe(ReadinessStatus.NotReachable);
|
|
1604
|
+
expect(readiness.methods).toEqual([]);
|
|
1605
|
+
expect(readiness.reasons[0]).toBe(
|
|
1606
|
+
"No verified notification method - cannot be paged",
|
|
1607
|
+
);
|
|
1608
|
+
expect(readiness.reasons[1]).toContain(
|
|
1609
|
+
"add and verify a notification method",
|
|
1610
|
+
);
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
test("methods that exist but are UNVERIFIED are still NotReachable, and are named so the fix is one click", async () => {
|
|
1614
|
+
emailFindBy.mockResolvedValue([
|
|
1615
|
+
emailMethod({
|
|
1616
|
+
userId: USER_A_ID,
|
|
1617
|
+
email: RAW_NOTIFICATION_EMAIL,
|
|
1618
|
+
isVerified: false,
|
|
1619
|
+
}),
|
|
1620
|
+
] as never);
|
|
1621
|
+
smsFindBy.mockResolvedValue([
|
|
1622
|
+
smsMethod({ userId: USER_A_ID, phone: RAW_SMS_PHONE, isVerified: false }),
|
|
1623
|
+
] as never);
|
|
1624
|
+
|
|
1625
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1626
|
+
|
|
1627
|
+
expect(readiness.status).toBe(ReadinessStatus.NotReachable);
|
|
1628
|
+
expect(readiness.reasons[1]).toBe(
|
|
1629
|
+
"Added Email, SMS but never verified - unverified methods are never used",
|
|
1630
|
+
);
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
test("an unverified method is still LISTED, because the UI has to show what to go and verify", async () => {
|
|
1634
|
+
emailFindBy.mockResolvedValue([
|
|
1635
|
+
emailMethod({
|
|
1636
|
+
userId: USER_A_ID,
|
|
1637
|
+
email: RAW_NOTIFICATION_EMAIL,
|
|
1638
|
+
isVerified: false,
|
|
1639
|
+
}),
|
|
1640
|
+
] as never);
|
|
1641
|
+
|
|
1642
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1643
|
+
|
|
1644
|
+
expect(readiness.methods).toHaveLength(1);
|
|
1645
|
+
expect(readiness.methods[0]!.isVerified).toBe(false);
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
test("NotReachable suppresses the coverage prose but NOT the coverage grid itself", async () => {
|
|
1649
|
+
setSeverities({
|
|
1650
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1654
|
+
|
|
1655
|
+
expect(readiness.status).toBe(ReadinessStatus.NotReachable);
|
|
1656
|
+
expect(readiness.coverage).toHaveLength(2);
|
|
1657
|
+
expect(
|
|
1658
|
+
readiness.reasons.some((reason: string): boolean => {
|
|
1659
|
+
return reason.includes("No rules for");
|
|
1660
|
+
}),
|
|
1661
|
+
).toBe(false);
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
test("a WEBHOOK alone is enough to be reachable - it has no verification concept at all", async () => {
|
|
1665
|
+
webhookFindBy.mockResolvedValue([
|
|
1666
|
+
webhookMethod({ userId: USER_A_ID, name: RAW_WEBHOOK_NAME }),
|
|
1667
|
+
] as never);
|
|
1668
|
+
|
|
1669
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1670
|
+
|
|
1671
|
+
expect(readiness.status).not.toBe(ReadinessStatus.NotReachable);
|
|
1672
|
+
expect(readiness.status).toBe(ReadinessStatus.Ready);
|
|
1673
|
+
expect(readiness.methods[0]!.methodType).toBe(ReadinessMethodType.Webhook);
|
|
1674
|
+
expect(readiness.methods[0]!.isVerified).toBe(true);
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
test("ONE uncovered cell is PartiallyReady - the other three of four cells do not rescue it", async () => {
|
|
1678
|
+
setSeverities({
|
|
1679
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
1680
|
+
alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
|
|
1681
|
+
});
|
|
1682
|
+
pushFindBy.mockResolvedValue([
|
|
1683
|
+
pushMethod({
|
|
1684
|
+
userId: USER_A_ID,
|
|
1685
|
+
deviceName: RAW_PUSH_DEVICE,
|
|
1686
|
+
isVerified: true,
|
|
1687
|
+
}),
|
|
1688
|
+
] as never);
|
|
1689
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
1690
|
+
notificationRule({
|
|
1691
|
+
userId: USER_A_ID,
|
|
1692
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
1693
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
1694
|
+
}),
|
|
1695
|
+
notificationRule({
|
|
1696
|
+
userId: USER_A_ID,
|
|
1697
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
1698
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
1699
|
+
}),
|
|
1700
|
+
notificationRule({
|
|
1701
|
+
userId: USER_A_ID,
|
|
1702
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
1703
|
+
alertSeverityId: ALERT_SEVERITY_1_ID,
|
|
1704
|
+
}),
|
|
1705
|
+
] as never);
|
|
1706
|
+
|
|
1707
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1708
|
+
|
|
1709
|
+
expect(readiness.coverage).toHaveLength(4);
|
|
1710
|
+
expect(readiness.status).toBe(ReadinessStatus.PartiallyReady);
|
|
1711
|
+
expect(readiness.reasons).toEqual([
|
|
1712
|
+
"No rules for Warning alert episodes - pages fall back to Push",
|
|
1713
|
+
]);
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
test("every cell covered is Ready, with nothing left to say", async () => {
|
|
1717
|
+
setSeverities({
|
|
1718
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
1719
|
+
alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
|
|
1720
|
+
});
|
|
1721
|
+
pushFindBy.mockResolvedValue([
|
|
1722
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
1723
|
+
] as never);
|
|
1724
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
1725
|
+
notificationRule({
|
|
1726
|
+
userId: USER_A_ID,
|
|
1727
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
1728
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
1729
|
+
}),
|
|
1730
|
+
notificationRule({
|
|
1731
|
+
userId: USER_A_ID,
|
|
1732
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
1733
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
1734
|
+
}),
|
|
1735
|
+
notificationRule({
|
|
1736
|
+
userId: USER_A_ID,
|
|
1737
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
1738
|
+
alertSeverityId: ALERT_SEVERITY_1_ID,
|
|
1739
|
+
}),
|
|
1740
|
+
notificationRule({
|
|
1741
|
+
userId: USER_A_ID,
|
|
1742
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
|
|
1743
|
+
alertSeverityId: ALERT_SEVERITY_1_ID,
|
|
1744
|
+
}),
|
|
1745
|
+
] as never);
|
|
1746
|
+
|
|
1747
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1748
|
+
|
|
1749
|
+
expect(readiness.status).toBe(ReadinessStatus.Ready);
|
|
1750
|
+
expect(readiness.reasons).toEqual([]);
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
test("every cell MUTED is Ready too - deliberate silence is not a gap", async () => {
|
|
1754
|
+
setSeverities({
|
|
1755
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
1756
|
+
});
|
|
1757
|
+
pushFindBy.mockResolvedValue([
|
|
1758
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
1759
|
+
] as never);
|
|
1760
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
1761
|
+
notificationRule({
|
|
1762
|
+
userId: USER_A_ID,
|
|
1763
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
1764
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
1765
|
+
isOptOut: true,
|
|
1766
|
+
}),
|
|
1767
|
+
notificationRule({
|
|
1768
|
+
userId: USER_A_ID,
|
|
1769
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
1770
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
1771
|
+
isOptOut: true,
|
|
1772
|
+
}),
|
|
1773
|
+
] as never);
|
|
1774
|
+
|
|
1775
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1776
|
+
|
|
1777
|
+
expect(readiness.status).toBe(ReadinessStatus.Ready);
|
|
1778
|
+
expect(readiness.reasons).toEqual([]);
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
test("a mix of covered and muted cells is Ready", async () => {
|
|
1782
|
+
setSeverities({
|
|
1783
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
1784
|
+
});
|
|
1785
|
+
pushFindBy.mockResolvedValue([
|
|
1786
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
1787
|
+
] as never);
|
|
1788
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
1789
|
+
notificationRule({
|
|
1790
|
+
userId: USER_A_ID,
|
|
1791
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
1792
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
1793
|
+
}),
|
|
1794
|
+
notificationRule({
|
|
1795
|
+
userId: USER_A_ID,
|
|
1796
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
1797
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
1798
|
+
isOptOut: true,
|
|
1799
|
+
}),
|
|
1800
|
+
] as never);
|
|
1801
|
+
|
|
1802
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1803
|
+
|
|
1804
|
+
expect(readiness.status).toBe(ReadinessStatus.Ready);
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
test("NotReachable wins over an uncovered cell - nothing can reach them, so coverage is moot", async () => {
|
|
1808
|
+
setSeverities({
|
|
1809
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
1810
|
+
});
|
|
1811
|
+
telegramFindBy.mockResolvedValue([
|
|
1812
|
+
telegramMethod({
|
|
1813
|
+
userId: USER_A_ID,
|
|
1814
|
+
handle: RAW_TELEGRAM_HANDLE,
|
|
1815
|
+
isVerified: false,
|
|
1816
|
+
}),
|
|
1817
|
+
] as never);
|
|
1818
|
+
|
|
1819
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1820
|
+
|
|
1821
|
+
expect(readiness.status).toBe(ReadinessStatus.NotReachable);
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
/*
|
|
1825
|
+
* A verified method on a channel the project has switched off is not a way to
|
|
1826
|
+
* reach anybody. Status used to be computed from `isVerified` alone, which
|
|
1827
|
+
* meant these responders rendered Ready and GREEN while being completely
|
|
1828
|
+
* unpageable - the single most dangerous output this service can produce,
|
|
1829
|
+
* because the whole point of the table is that a green row can be trusted.
|
|
1830
|
+
*/
|
|
1831
|
+
describe("project channel switches", () => {
|
|
1832
|
+
test("verified SMS and Call, both switched off, is NotReachable rather than Ready", async () => {
|
|
1833
|
+
projectFindOneById.mockResolvedValue(
|
|
1834
|
+
makeProject({
|
|
1835
|
+
enableSmsNotifications: false,
|
|
1836
|
+
enableCallNotifications: false,
|
|
1837
|
+
}) as never,
|
|
1838
|
+
);
|
|
1839
|
+
smsFindBy.mockResolvedValue([
|
|
1840
|
+
smsMethod({
|
|
1841
|
+
userId: USER_A_ID,
|
|
1842
|
+
phone: RAW_SMS_PHONE,
|
|
1843
|
+
isVerified: true,
|
|
1844
|
+
}),
|
|
1845
|
+
] as never);
|
|
1846
|
+
callFindBy.mockResolvedValue([
|
|
1847
|
+
callMethod({
|
|
1848
|
+
userId: USER_A_ID,
|
|
1849
|
+
phone: RAW_CALL_PHONE,
|
|
1850
|
+
isVerified: true,
|
|
1851
|
+
}),
|
|
1852
|
+
] as never);
|
|
1853
|
+
|
|
1854
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1855
|
+
|
|
1856
|
+
expect(readiness.status).toBe(ReadinessStatus.NotReachable);
|
|
1857
|
+
expect(readiness.reasons[1]).toBe(
|
|
1858
|
+
"Every method they have verified is on SMS, Call, and this project has those channels switched off - that is a project setting, not something this user can fix",
|
|
1859
|
+
);
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
test("the reason names the channel AND says whose problem it is, because the user cannot fix it", async () => {
|
|
1863
|
+
projectFindOneById.mockResolvedValue(
|
|
1864
|
+
makeProject({ enableWhatsAppNotifications: false }) as never,
|
|
1865
|
+
);
|
|
1866
|
+
whatsAppFindBy.mockResolvedValue([
|
|
1867
|
+
whatsAppMethod({
|
|
1868
|
+
userId: USER_A_ID,
|
|
1869
|
+
phone: RAW_WHATSAPP_PHONE,
|
|
1870
|
+
isVerified: true,
|
|
1871
|
+
}),
|
|
1872
|
+
] as never);
|
|
1873
|
+
|
|
1874
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1875
|
+
|
|
1876
|
+
/*
|
|
1877
|
+
* "Add and verify a notification method" is the WRONG instruction here -
|
|
1878
|
+
* they have done that, and doing it again on the same channel changes
|
|
1879
|
+
* nothing. The sentence has to send an admin to the project settings.
|
|
1880
|
+
*/
|
|
1881
|
+
expect(readiness.reasons[1]).toContain("WhatsApp");
|
|
1882
|
+
expect(readiness.reasons[1]).toContain("project setting");
|
|
1883
|
+
expect(
|
|
1884
|
+
readiness.reasons.some((reason: string): boolean => {
|
|
1885
|
+
return reason.includes("add and verify");
|
|
1886
|
+
}),
|
|
1887
|
+
).toBe(false);
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
test("the methods are still LISTED - an admin has to see what is stranded", async () => {
|
|
1891
|
+
projectFindOneById.mockResolvedValue(
|
|
1892
|
+
makeProject({ enableTelegramNotifications: false }) as never,
|
|
1893
|
+
);
|
|
1894
|
+
telegramFindBy.mockResolvedValue([
|
|
1895
|
+
telegramMethod({
|
|
1896
|
+
userId: USER_A_ID,
|
|
1897
|
+
handle: RAW_TELEGRAM_HANDLE,
|
|
1898
|
+
isVerified: true,
|
|
1899
|
+
}),
|
|
1900
|
+
] as never);
|
|
1901
|
+
|
|
1902
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1903
|
+
|
|
1904
|
+
expect(methodTypes(readiness)).toEqual([ReadinessMethodType.Telegram]);
|
|
1905
|
+
expect(readiness.methods[0]!.isVerified).toBe(true);
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
test("one working channel is enough - a stranded second channel does not make them unreachable", async () => {
|
|
1909
|
+
projectFindOneById.mockResolvedValue(
|
|
1910
|
+
makeProject({ enableSmsNotifications: false }) as never,
|
|
1911
|
+
);
|
|
1912
|
+
pushFindBy.mockResolvedValue([
|
|
1913
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
1914
|
+
] as never);
|
|
1915
|
+
smsFindBy.mockResolvedValue([
|
|
1916
|
+
smsMethod({
|
|
1917
|
+
userId: USER_A_ID,
|
|
1918
|
+
phone: RAW_SMS_PHONE,
|
|
1919
|
+
isVerified: true,
|
|
1920
|
+
}),
|
|
1921
|
+
] as never);
|
|
1922
|
+
|
|
1923
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1924
|
+
|
|
1925
|
+
/*
|
|
1926
|
+
* And NOTHING is said about the switched-off SMS. Saying it would put a
|
|
1927
|
+
* warning sentence on every user in a project that has switched SMS off,
|
|
1928
|
+
* attached to people who have nothing wrong with them - which is how a
|
|
1929
|
+
* readiness surface teaches admins to stop reading it.
|
|
1930
|
+
*/
|
|
1931
|
+
expect(readiness.status).toBe(ReadinessStatus.Ready);
|
|
1932
|
+
expect(readiness.reasons).toEqual([]);
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
test.each([
|
|
1936
|
+
[
|
|
1937
|
+
"Push",
|
|
1938
|
+
(): void => {
|
|
1939
|
+
pushFindBy.mockResolvedValue([
|
|
1940
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
1941
|
+
] as never);
|
|
1942
|
+
},
|
|
1943
|
+
],
|
|
1944
|
+
[
|
|
1945
|
+
"Email",
|
|
1946
|
+
(): void => {
|
|
1947
|
+
emailFindBy.mockResolvedValue([
|
|
1948
|
+
emailMethod({
|
|
1949
|
+
userId: USER_A_ID,
|
|
1950
|
+
email: RAW_NOTIFICATION_EMAIL,
|
|
1951
|
+
isVerified: true,
|
|
1952
|
+
}),
|
|
1953
|
+
] as never);
|
|
1954
|
+
},
|
|
1955
|
+
],
|
|
1956
|
+
[
|
|
1957
|
+
"Webhook",
|
|
1958
|
+
(): void => {
|
|
1959
|
+
webhookFindBy.mockResolvedValue([
|
|
1960
|
+
webhookMethod({ userId: USER_A_ID, name: RAW_WEBHOOK_NAME }),
|
|
1961
|
+
] as never);
|
|
1962
|
+
},
|
|
1963
|
+
],
|
|
1964
|
+
])(
|
|
1965
|
+
"%s has no project switch to be gated by, so every switch being off leaves it usable",
|
|
1966
|
+
async (_label: string, give: () => void) => {
|
|
1967
|
+
projectFindOneById.mockResolvedValue(
|
|
1968
|
+
makeProject({
|
|
1969
|
+
enableSmsNotifications: false,
|
|
1970
|
+
enableCallNotifications: false,
|
|
1971
|
+
enableWhatsAppNotifications: false,
|
|
1972
|
+
enableTelegramNotifications: false,
|
|
1973
|
+
}) as never,
|
|
1974
|
+
);
|
|
1975
|
+
give();
|
|
1976
|
+
|
|
1977
|
+
expect((await onlyUser()).status).toBe(ReadinessStatus.Ready);
|
|
1978
|
+
},
|
|
1979
|
+
);
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
test("a project with no severities configured has no cells, so a reachable responder is Ready", async () => {
|
|
1983
|
+
pushFindBy.mockResolvedValue([
|
|
1984
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
1985
|
+
] as never);
|
|
1986
|
+
|
|
1987
|
+
const readiness: UserReadiness = await onlyUser();
|
|
1988
|
+
|
|
1989
|
+
expect(readiness.coverage).toEqual([]);
|
|
1990
|
+
expect(readiness.status).toBe(ReadinessStatus.Ready);
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
test("the user's display name falls back to their login email, then to a placeholder", async () => {
|
|
1994
|
+
const nameless: User = new User();
|
|
1995
|
+
nameless.id = USER_A_ID;
|
|
1996
|
+
nameless.email = new Email(USER_A_LOGIN_EMAIL);
|
|
1997
|
+
|
|
1998
|
+
const anonymous: User = new User();
|
|
1999
|
+
anonymous.id = USER_B_ID;
|
|
2000
|
+
|
|
2001
|
+
userDirectory = [nameless, anonymous];
|
|
2002
|
+
attachDirectly(USER_A_ID, USER_B_ID);
|
|
2003
|
+
|
|
2004
|
+
const summary: ReadinessSummary = await policySummary();
|
|
2005
|
+
const names: Array<string> = summary.users.map(
|
|
2006
|
+
(user: UserReadiness): string => {
|
|
2007
|
+
return user.userName;
|
|
2008
|
+
},
|
|
2009
|
+
);
|
|
2010
|
+
|
|
2011
|
+
expect(names).toContain(USER_A_LOGIN_EMAIL);
|
|
2012
|
+
expect(names).toContain("Unknown User");
|
|
2013
|
+
});
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
/*
|
|
2017
|
+
* ---------------------------------------------------------------------------
|
|
2018
|
+
* (D) Summary roll-up: counts and ordering.
|
|
2019
|
+
* ---------------------------------------------------------------------------
|
|
2020
|
+
*/
|
|
2021
|
+
describe("summary roll-up", () => {
|
|
2022
|
+
beforeEach(() => {
|
|
2023
|
+
attachDirectly(USER_A_ID, USER_B_ID, USER_C_ID);
|
|
2024
|
+
setSeverities({
|
|
2025
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
/*
|
|
2029
|
+
* One responder in each of the three states, so the counts and the sort can
|
|
2030
|
+
* be read off the same fixture:
|
|
2031
|
+
* Ada - verified push, every cell covered -> Ready
|
|
2032
|
+
* Grace - verified push, nothing covered -> PartiallyReady
|
|
2033
|
+
* Katherine - nothing at all -> NotReachable
|
|
2034
|
+
*/
|
|
2035
|
+
pushFindBy.mockResolvedValue([
|
|
2036
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
2037
|
+
pushMethod({ userId: USER_B_ID, isVerified: true }),
|
|
2038
|
+
] as never);
|
|
2039
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2040
|
+
notificationRule({
|
|
2041
|
+
userId: USER_A_ID,
|
|
2042
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2043
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2044
|
+
}),
|
|
2045
|
+
notificationRule({
|
|
2046
|
+
userId: USER_A_ID,
|
|
2047
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
2048
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2049
|
+
}),
|
|
2050
|
+
] as never);
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
test("each status is counted exactly once, and the counts sum to the user list", async () => {
|
|
2054
|
+
const summary: ReadinessSummary = await policySummary();
|
|
2055
|
+
|
|
2056
|
+
expect(summary.readyCount).toBe(1);
|
|
2057
|
+
expect(summary.partiallyReadyCount).toBe(1);
|
|
2058
|
+
expect(summary.notReachableCount).toBe(1);
|
|
2059
|
+
expect(
|
|
2060
|
+
summary.readyCount +
|
|
2061
|
+
summary.partiallyReadyCount +
|
|
2062
|
+
summary.notReachableCount,
|
|
2063
|
+
).toBe(summary.users.length);
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
test("the most broken responder sorts first - the table is read by somebody looking for a problem", async () => {
|
|
2067
|
+
const summary: ReadinessSummary = await policySummary();
|
|
2068
|
+
|
|
2069
|
+
expect(
|
|
2070
|
+
summary.users.map((user: UserReadiness): ReadinessStatus => {
|
|
2071
|
+
return user.status;
|
|
2072
|
+
}),
|
|
2073
|
+
).toEqual([
|
|
2074
|
+
ReadinessStatus.NotReachable,
|
|
2075
|
+
ReadinessStatus.PartiallyReady,
|
|
2076
|
+
ReadinessStatus.Ready,
|
|
2077
|
+
]);
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
test("responders of equal status sort by name, so the list is stable between renders", async () => {
|
|
2081
|
+
pushFindBy.mockResolvedValue([] as never);
|
|
2082
|
+
|
|
2083
|
+
const summary: ReadinessSummary = await policySummary();
|
|
2084
|
+
|
|
2085
|
+
expect(
|
|
2086
|
+
summary.users.map((user: UserReadiness): string => {
|
|
2087
|
+
return user.userName;
|
|
2088
|
+
}),
|
|
2089
|
+
).toEqual(["Ada Lovelace", "Grace Hopper", "Katherine Johnson"]);
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
test("the summary echoes back the scope it was asked about", async () => {
|
|
2093
|
+
const summary: ReadinessSummary = await policySummary();
|
|
2094
|
+
|
|
2095
|
+
expect(summary.projectId.toString()).toBe(PROJECT_ID.toString());
|
|
2096
|
+
expect(summary.onCallDutyPolicyId?.toString()).toBe(POLICY_ID.toString());
|
|
2097
|
+
});
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
/*
|
|
2101
|
+
* ---------------------------------------------------------------------------
|
|
2102
|
+
* (E) Coverage: the right severity model for the right rule type.
|
|
2103
|
+
*
|
|
2104
|
+
* Incident and incident-episode rules are scoped by IncidentSeverity; alert and
|
|
2105
|
+
* alert-episode by AlertSeverity. Crossing them is not a cosmetic mistake - it
|
|
2106
|
+
* produces a cell keyed on an id that the paging path will never present, so
|
|
2107
|
+
* the cell can NEVER be satisfied, and a rule written against the wrong column
|
|
2108
|
+
* silently covers nothing. That is the exact shape of Gap G.
|
|
2109
|
+
* ---------------------------------------------------------------------------
|
|
2110
|
+
*/
|
|
2111
|
+
describe("coverage", () => {
|
|
2112
|
+
beforeEach(() => {
|
|
2113
|
+
attachDirectly(USER_A_ID);
|
|
2114
|
+
pushFindBy.mockResolvedValue([
|
|
2115
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
2116
|
+
] as never);
|
|
2117
|
+
setSeverities({
|
|
2118
|
+
incident: [
|
|
2119
|
+
incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1"),
|
|
2120
|
+
incidentSeverity(INCIDENT_SEVERITY_2_ID, "Sev2"),
|
|
2121
|
+
],
|
|
2122
|
+
alert: [
|
|
2123
|
+
alertSeverity(ALERT_SEVERITY_1_ID, "Warning"),
|
|
2124
|
+
alertSeverity(ALERT_SEVERITY_2_ID, "Critical"),
|
|
2125
|
+
],
|
|
2126
|
+
});
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
test("the grid is the four PAGING rule types crossed with their own severity list", async () => {
|
|
2130
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2131
|
+
|
|
2132
|
+
expect(readiness.coverage).toHaveLength(8);
|
|
2133
|
+
|
|
2134
|
+
const byRuleType: Map<NotificationRuleType, Array<string>> = new Map<
|
|
2135
|
+
NotificationRuleType,
|
|
2136
|
+
Array<string>
|
|
2137
|
+
>();
|
|
2138
|
+
|
|
2139
|
+
for (const cell of readiness.coverage) {
|
|
2140
|
+
const existing: Array<string> = byRuleType.get(cell.ruleType) || [];
|
|
2141
|
+
existing.push(cell.severityId?.toString() || "");
|
|
2142
|
+
byRuleType.set(cell.ruleType, existing);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
expect(
|
|
2146
|
+
byRuleType.get(NotificationRuleType.ON_CALL_EXECUTED_INCIDENT),
|
|
2147
|
+
).toEqual([
|
|
2148
|
+
INCIDENT_SEVERITY_1_ID.toString(),
|
|
2149
|
+
INCIDENT_SEVERITY_2_ID.toString(),
|
|
2150
|
+
]);
|
|
2151
|
+
expect(
|
|
2152
|
+
byRuleType.get(NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE),
|
|
2153
|
+
).toEqual([
|
|
2154
|
+
INCIDENT_SEVERITY_1_ID.toString(),
|
|
2155
|
+
INCIDENT_SEVERITY_2_ID.toString(),
|
|
2156
|
+
]);
|
|
2157
|
+
expect(byRuleType.get(NotificationRuleType.ON_CALL_EXECUTED_ALERT)).toEqual(
|
|
2158
|
+
[ALERT_SEVERITY_1_ID.toString(), ALERT_SEVERITY_2_ID.toString()],
|
|
2159
|
+
);
|
|
2160
|
+
expect(
|
|
2161
|
+
byRuleType.get(NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE),
|
|
2162
|
+
).toEqual([ALERT_SEVERITY_1_ID.toString(), ALERT_SEVERITY_2_ID.toString()]);
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
test("no alert cell is ever keyed on an incident severity, and vice versa", async () => {
|
|
2166
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2167
|
+
|
|
2168
|
+
const incidentIds: Set<string> = new Set<string>([
|
|
2169
|
+
INCIDENT_SEVERITY_1_ID.toString(),
|
|
2170
|
+
INCIDENT_SEVERITY_2_ID.toString(),
|
|
2171
|
+
]);
|
|
2172
|
+
const alertIds: Set<string> = new Set<string>([
|
|
2173
|
+
ALERT_SEVERITY_1_ID.toString(),
|
|
2174
|
+
ALERT_SEVERITY_2_ID.toString(),
|
|
2175
|
+
]);
|
|
2176
|
+
|
|
2177
|
+
for (const cell of readiness.coverage) {
|
|
2178
|
+
const isIncidentRule: boolean =
|
|
2179
|
+
cell.ruleType === NotificationRuleType.ON_CALL_EXECUTED_INCIDENT ||
|
|
2180
|
+
cell.ruleType ===
|
|
2181
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE;
|
|
2182
|
+
const id: string = cell.severityId?.toString() || "";
|
|
2183
|
+
|
|
2184
|
+
expect(isIncidentRule ? incidentIds.has(id) : alertIds.has(id)).toBe(
|
|
2185
|
+
true,
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
test("cells carry the severity NAME, so a reason sentence can say Sev2 rather than a uuid", async () => {
|
|
2191
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2192
|
+
|
|
2193
|
+
expect(
|
|
2194
|
+
cellFor(
|
|
2195
|
+
readiness,
|
|
2196
|
+
NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
|
|
2197
|
+
ALERT_SEVERITY_2_ID,
|
|
2198
|
+
).severityName,
|
|
2199
|
+
).toBe("Critical");
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
test("a rule covers ONLY its own (ruleType, severity) cell", async () => {
|
|
2203
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2204
|
+
notificationRule({
|
|
2205
|
+
userId: USER_A_ID,
|
|
2206
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2207
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2208
|
+
}),
|
|
2209
|
+
] as never);
|
|
2210
|
+
|
|
2211
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2212
|
+
|
|
2213
|
+
expect(
|
|
2214
|
+
cellFor(
|
|
2215
|
+
readiness,
|
|
2216
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2217
|
+
INCIDENT_SEVERITY_1_ID,
|
|
2218
|
+
).hasRule,
|
|
2219
|
+
).toBe(true);
|
|
2220
|
+
|
|
2221
|
+
/*
|
|
2222
|
+
* The episode cell for the SAME severity is a different cell. Gap F was
|
|
2223
|
+
* exactly this: episode pages fired against rules that only ever existed
|
|
2224
|
+
* for the non-episode rule type.
|
|
2225
|
+
*/
|
|
2226
|
+
expect(
|
|
2227
|
+
cellFor(
|
|
2228
|
+
readiness,
|
|
2229
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
2230
|
+
INCIDENT_SEVERITY_1_ID,
|
|
2231
|
+
).hasRule,
|
|
2232
|
+
).toBe(false);
|
|
2233
|
+
expect(
|
|
2234
|
+
cellFor(
|
|
2235
|
+
readiness,
|
|
2236
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2237
|
+
INCIDENT_SEVERITY_2_ID,
|
|
2238
|
+
).hasRule,
|
|
2239
|
+
).toBe(false);
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
test("a handoff rule covers NOTHING, however its severity is set (severity-only matching defect)", async () => {
|
|
2243
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2244
|
+
notificationRule({
|
|
2245
|
+
userId: USER_A_ID,
|
|
2246
|
+
ruleType: NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
2247
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2248
|
+
alertSeverityId: ALERT_SEVERITY_1_ID,
|
|
2249
|
+
}),
|
|
2250
|
+
notificationRule({
|
|
2251
|
+
userId: USER_A_ID,
|
|
2252
|
+
ruleType: NotificationRuleType.WHEN_USER_GOES_ON_CALL,
|
|
2253
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2254
|
+
}),
|
|
2255
|
+
] as never);
|
|
2256
|
+
|
|
2257
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2258
|
+
|
|
2259
|
+
expect(
|
|
2260
|
+
readiness.coverage.every((cell: ReadinessCoverageCell): boolean => {
|
|
2261
|
+
return !cell.hasRule && !cell.isOptOut;
|
|
2262
|
+
}),
|
|
2263
|
+
).toBe(true);
|
|
2264
|
+
expect(readiness.status).toBe(ReadinessStatus.PartiallyReady);
|
|
2265
|
+
});
|
|
2266
|
+
|
|
2267
|
+
test("an ALERT rule carrying only an incidentSeverityId covers nothing - it matches no page at runtime either", async () => {
|
|
2268
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2269
|
+
notificationRule({
|
|
2270
|
+
userId: USER_A_ID,
|
|
2271
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
2272
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2273
|
+
}),
|
|
2274
|
+
] as never);
|
|
2275
|
+
|
|
2276
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2277
|
+
|
|
2278
|
+
expect(
|
|
2279
|
+
readiness.coverage.some((cell: ReadinessCoverageCell): boolean => {
|
|
2280
|
+
return cell.hasRule;
|
|
2281
|
+
}),
|
|
2282
|
+
).toBe(false);
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
test("an INCIDENT rule whose incidentSeverityId is an ALERT severity id matches no cell", async () => {
|
|
2286
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2287
|
+
notificationRule({
|
|
2288
|
+
userId: USER_A_ID,
|
|
2289
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2290
|
+
incidentSeverityId: ALERT_SEVERITY_1_ID,
|
|
2291
|
+
}),
|
|
2292
|
+
] as never);
|
|
2293
|
+
|
|
2294
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2295
|
+
|
|
2296
|
+
expect(
|
|
2297
|
+
readiness.coverage.some((cell: ReadinessCoverageCell): boolean => {
|
|
2298
|
+
return cell.hasRule;
|
|
2299
|
+
}),
|
|
2300
|
+
).toBe(false);
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
test("a severity-scoped rule with a NULL severity is not coverage - it is the Gap G corpse", async () => {
|
|
2304
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2305
|
+
notificationRule({
|
|
2306
|
+
userId: USER_A_ID,
|
|
2307
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
2308
|
+
}),
|
|
2309
|
+
] as never);
|
|
2310
|
+
|
|
2311
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2312
|
+
|
|
2313
|
+
expect(
|
|
2314
|
+
readiness.coverage.some((cell: ReadinessCoverageCell): boolean => {
|
|
2315
|
+
return cell.hasRule;
|
|
2316
|
+
}),
|
|
2317
|
+
).toBe(false);
|
|
2318
|
+
expect(readiness.status).toBe(ReadinessStatus.PartiallyReady);
|
|
2319
|
+
});
|
|
2320
|
+
|
|
2321
|
+
test("one user's rules never satisfy another user's cells", async () => {
|
|
2322
|
+
attachDirectly(USER_A_ID, USER_B_ID);
|
|
2323
|
+
pushFindBy.mockResolvedValue([
|
|
2324
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
2325
|
+
pushMethod({ userId: USER_B_ID, isVerified: true }),
|
|
2326
|
+
] as never);
|
|
2327
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2328
|
+
notificationRule({
|
|
2329
|
+
userId: USER_A_ID,
|
|
2330
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2331
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2332
|
+
}),
|
|
2333
|
+
] as never);
|
|
2334
|
+
|
|
2335
|
+
const summary: ReadinessSummary = await policySummary();
|
|
2336
|
+
const grace: UserReadiness = summary.users.find(
|
|
2337
|
+
(user: UserReadiness): boolean => {
|
|
2338
|
+
return user.userId.toString() === USER_B_ID.toString();
|
|
2339
|
+
},
|
|
2340
|
+
)!;
|
|
2341
|
+
|
|
2342
|
+
expect(
|
|
2343
|
+
cellFor(
|
|
2344
|
+
grace,
|
|
2345
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2346
|
+
INCIDENT_SEVERITY_1_ID,
|
|
2347
|
+
).hasRule,
|
|
2348
|
+
).toBe(false);
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2351
|
+
test("a severity with no name still produces a usable cell rather than being dropped", async () => {
|
|
2352
|
+
const unnamed: IncidentSeverity = new IncidentSeverity();
|
|
2353
|
+
unnamed.id = INCIDENT_SEVERITY_1_ID;
|
|
2354
|
+
|
|
2355
|
+
setSeverities({ incident: [unnamed] });
|
|
2356
|
+
|
|
2357
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2358
|
+
|
|
2359
|
+
expect(
|
|
2360
|
+
cellFor(
|
|
2361
|
+
readiness,
|
|
2362
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2363
|
+
INCIDENT_SEVERITY_1_ID,
|
|
2364
|
+
).severityName,
|
|
2365
|
+
).toBe("Unnamed Severity");
|
|
2366
|
+
});
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
/*
|
|
2370
|
+
* ---------------------------------------------------------------------------
|
|
2371
|
+
* (F) Opt-out: the nullable column that ruins everything if you test it for
|
|
2372
|
+
* `false`.
|
|
2373
|
+
* ---------------------------------------------------------------------------
|
|
2374
|
+
*/
|
|
2375
|
+
describe("opt-out", () => {
|
|
2376
|
+
beforeEach(() => {
|
|
2377
|
+
attachDirectly(USER_A_ID);
|
|
2378
|
+
pushFindBy.mockResolvedValue([
|
|
2379
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
2380
|
+
] as never);
|
|
2381
|
+
setSeverities({
|
|
2382
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
2383
|
+
});
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
test("a rule with isOptOut NULL is a REAL rule and counts as coverage", async () => {
|
|
2387
|
+
/*
|
|
2388
|
+
* THE test this file exists for as much as any other. isOptOut is nullable
|
|
2389
|
+
* and was added long after these rows started existing, so it is NULL on
|
|
2390
|
+
* every rule in every pre-existing install. The naive `isOptOut === false`
|
|
2391
|
+
* split would classify all of them as neither rules nor opt-outs and report
|
|
2392
|
+
* a fully-configured project as entirely unready - a red table that teaches
|
|
2393
|
+
* admins to ignore red tables.
|
|
2394
|
+
*/
|
|
2395
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2396
|
+
notificationRule({
|
|
2397
|
+
userId: USER_A_ID,
|
|
2398
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2399
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2400
|
+
}),
|
|
2401
|
+
] as never);
|
|
2402
|
+
|
|
2403
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2404
|
+
const cell: ReadinessCoverageCell = cellFor(
|
|
2405
|
+
readiness,
|
|
2406
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2407
|
+
INCIDENT_SEVERITY_1_ID,
|
|
2408
|
+
);
|
|
2409
|
+
|
|
2410
|
+
expect(cell.hasRule).toBe(true);
|
|
2411
|
+
expect(cell.isOptOut).toBe(false);
|
|
2412
|
+
});
|
|
2413
|
+
|
|
2414
|
+
test("a rule with isOptOut explicitly false is also coverage", async () => {
|
|
2415
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2416
|
+
notificationRule({
|
|
2417
|
+
userId: USER_A_ID,
|
|
2418
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2419
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2420
|
+
isOptOut: false,
|
|
2421
|
+
}),
|
|
2422
|
+
] as never);
|
|
2423
|
+
|
|
2424
|
+
expect(
|
|
2425
|
+
cellFor(
|
|
2426
|
+
await onlyUser(),
|
|
2427
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2428
|
+
INCIDENT_SEVERITY_1_ID,
|
|
2429
|
+
).hasRule,
|
|
2430
|
+
).toBe(true);
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2433
|
+
test("an opt-out row is surfaced as isOptOut and is NOT counted as a rule", async () => {
|
|
2434
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2435
|
+
notificationRule({
|
|
2436
|
+
userId: USER_A_ID,
|
|
2437
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2438
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2439
|
+
isOptOut: true,
|
|
2440
|
+
}),
|
|
2441
|
+
] as never);
|
|
2442
|
+
|
|
2443
|
+
const cell: ReadinessCoverageCell = cellFor(
|
|
2444
|
+
await onlyUser(),
|
|
2445
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2446
|
+
INCIDENT_SEVERITY_1_ID,
|
|
2447
|
+
);
|
|
2448
|
+
|
|
2449
|
+
expect(cell.isOptOut).toBe(true);
|
|
2450
|
+
expect(cell.hasRule).toBe(false);
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2453
|
+
test("an opt-out cell is not a gap, so it never appears in a reason sentence", async () => {
|
|
2454
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2455
|
+
notificationRule({
|
|
2456
|
+
userId: USER_A_ID,
|
|
2457
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2458
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2459
|
+
isOptOut: true,
|
|
2460
|
+
}),
|
|
2461
|
+
] as never);
|
|
2462
|
+
|
|
2463
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2464
|
+
|
|
2465
|
+
expect(readiness.reasons).toEqual([
|
|
2466
|
+
"No rules for Sev1 incident episodes - pages fall back to Push",
|
|
2467
|
+
]);
|
|
2468
|
+
});
|
|
2469
|
+
|
|
2470
|
+
test("a cell with BOTH an opt-out row and a real rule reports both, and is not a gap", async () => {
|
|
2471
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
2472
|
+
notificationRule({
|
|
2473
|
+
userId: USER_A_ID,
|
|
2474
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2475
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2476
|
+
isOptOut: true,
|
|
2477
|
+
}),
|
|
2478
|
+
notificationRule({
|
|
2479
|
+
userId: USER_A_ID,
|
|
2480
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2481
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
2482
|
+
}),
|
|
2483
|
+
] as never);
|
|
2484
|
+
|
|
2485
|
+
const cell: ReadinessCoverageCell = cellFor(
|
|
2486
|
+
await onlyUser(),
|
|
2487
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2488
|
+
INCIDENT_SEVERITY_1_ID,
|
|
2489
|
+
);
|
|
2490
|
+
|
|
2491
|
+
expect(cell.hasRule).toBe(true);
|
|
2492
|
+
expect(cell.isOptOut).toBe(true);
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
test("opt-out rows are read alongside real rules, never filtered out in SQL", async () => {
|
|
2496
|
+
await policySummary();
|
|
2497
|
+
|
|
2498
|
+
const call: FindByCall = firstCall(notificationRuleFindBy);
|
|
2499
|
+
|
|
2500
|
+
/*
|
|
2501
|
+
* If the query filtered on isOptOut, the "muted" and "nothing configured"
|
|
2502
|
+
* cases would collapse into one - and telling them apart is the entire
|
|
2503
|
+
* reason the column exists.
|
|
2504
|
+
*/
|
|
2505
|
+
expect(call.query["isOptOut"]).toBeUndefined();
|
|
2506
|
+
expect(call.select?.["isOptOut"]).toBe(true);
|
|
2507
|
+
});
|
|
2508
|
+
});
|
|
2509
|
+
|
|
2510
|
+
/*
|
|
2511
|
+
* ---------------------------------------------------------------------------
|
|
2512
|
+
* (G) All seven channels.
|
|
2513
|
+
*
|
|
2514
|
+
* TeamComplianceService counted call/SMS/email/push only, so a responder whose
|
|
2515
|
+
* only method was Telegram, WhatsApp or Webhook was reported non-compliant
|
|
2516
|
+
* while the runtime paged them perfectly happily. A false alarm teaches admins
|
|
2517
|
+
* to ignore the table, which is worse than no table.
|
|
2518
|
+
* ---------------------------------------------------------------------------
|
|
2519
|
+
*/
|
|
2520
|
+
describe("notification channels", () => {
|
|
2521
|
+
beforeEach(() => {
|
|
2522
|
+
attachDirectly(USER_A_ID);
|
|
2523
|
+
});
|
|
2524
|
+
|
|
2525
|
+
test("a verified TELEGRAM handle alone makes a responder reachable (four-channel defect)", async () => {
|
|
2526
|
+
telegramFindBy.mockResolvedValue([
|
|
2527
|
+
telegramMethod({
|
|
2528
|
+
userId: USER_A_ID,
|
|
2529
|
+
handle: RAW_TELEGRAM_HANDLE,
|
|
2530
|
+
isVerified: true,
|
|
2531
|
+
}),
|
|
2532
|
+
] as never);
|
|
2533
|
+
|
|
2534
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2535
|
+
|
|
2536
|
+
expect(readiness.status).not.toBe(ReadinessStatus.NotReachable);
|
|
2537
|
+
expect(methodTypes(readiness)).toEqual([ReadinessMethodType.Telegram]);
|
|
2538
|
+
});
|
|
2539
|
+
|
|
2540
|
+
test("a verified WHATSAPP number alone makes a responder reachable (four-channel defect)", async () => {
|
|
2541
|
+
whatsAppFindBy.mockResolvedValue([
|
|
2542
|
+
whatsAppMethod({
|
|
2543
|
+
userId: USER_A_ID,
|
|
2544
|
+
phone: RAW_WHATSAPP_PHONE,
|
|
2545
|
+
isVerified: true,
|
|
2546
|
+
}),
|
|
2547
|
+
] as never);
|
|
2548
|
+
|
|
2549
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2550
|
+
|
|
2551
|
+
expect(readiness.status).not.toBe(ReadinessStatus.NotReachable);
|
|
2552
|
+
expect(methodTypes(readiness)).toEqual([ReadinessMethodType.WhatsApp]);
|
|
2553
|
+
});
|
|
2554
|
+
|
|
2555
|
+
test("a WEBHOOK alone makes a responder reachable (four-channel defect)", async () => {
|
|
2556
|
+
webhookFindBy.mockResolvedValue([
|
|
2557
|
+
webhookMethod({ userId: USER_A_ID, name: RAW_WEBHOOK_NAME }),
|
|
2558
|
+
] as never);
|
|
2559
|
+
|
|
2560
|
+
expect((await onlyUser()).status).not.toBe(ReadinessStatus.NotReachable);
|
|
2561
|
+
});
|
|
2562
|
+
|
|
2563
|
+
test("all seven channels are read, and listed in fallback-attempt order", async () => {
|
|
2564
|
+
pushFindBy.mockResolvedValue([
|
|
2565
|
+
pushMethod({
|
|
2566
|
+
userId: USER_A_ID,
|
|
2567
|
+
deviceName: RAW_PUSH_DEVICE,
|
|
2568
|
+
isVerified: true,
|
|
2569
|
+
}),
|
|
2570
|
+
] as never);
|
|
2571
|
+
emailFindBy.mockResolvedValue([
|
|
2572
|
+
emailMethod({
|
|
2573
|
+
userId: USER_A_ID,
|
|
2574
|
+
email: RAW_NOTIFICATION_EMAIL,
|
|
2575
|
+
isVerified: true,
|
|
2576
|
+
}),
|
|
2577
|
+
] as never);
|
|
2578
|
+
smsFindBy.mockResolvedValue([
|
|
2579
|
+
smsMethod({ userId: USER_A_ID, phone: RAW_SMS_PHONE, isVerified: true }),
|
|
2580
|
+
] as never);
|
|
2581
|
+
callFindBy.mockResolvedValue([
|
|
2582
|
+
callMethod({
|
|
2583
|
+
userId: USER_A_ID,
|
|
2584
|
+
phone: RAW_CALL_PHONE,
|
|
2585
|
+
isVerified: true,
|
|
2586
|
+
}),
|
|
2587
|
+
] as never);
|
|
2588
|
+
whatsAppFindBy.mockResolvedValue([
|
|
2589
|
+
whatsAppMethod({
|
|
2590
|
+
userId: USER_A_ID,
|
|
2591
|
+
phone: RAW_WHATSAPP_PHONE,
|
|
2592
|
+
isVerified: true,
|
|
2593
|
+
}),
|
|
2594
|
+
] as never);
|
|
2595
|
+
telegramFindBy.mockResolvedValue([
|
|
2596
|
+
telegramMethod({
|
|
2597
|
+
userId: USER_A_ID,
|
|
2598
|
+
handle: RAW_TELEGRAM_HANDLE,
|
|
2599
|
+
isVerified: true,
|
|
2600
|
+
}),
|
|
2601
|
+
] as never);
|
|
2602
|
+
webhookFindBy.mockResolvedValue([
|
|
2603
|
+
webhookMethod({ userId: USER_A_ID, name: RAW_WEBHOOK_NAME }),
|
|
2604
|
+
] as never);
|
|
2605
|
+
|
|
2606
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2607
|
+
|
|
2608
|
+
/*
|
|
2609
|
+
* Display order IS fallback order, deliberately: the first row an admin
|
|
2610
|
+
* reads is the channel a fallback page would actually arrive on.
|
|
2611
|
+
*/
|
|
2612
|
+
expect(methodTypes(readiness)).toEqual([
|
|
2613
|
+
ReadinessMethodType.Push,
|
|
2614
|
+
ReadinessMethodType.Email,
|
|
2615
|
+
ReadinessMethodType.SMS,
|
|
2616
|
+
ReadinessMethodType.Call,
|
|
2617
|
+
ReadinessMethodType.WhatsApp,
|
|
2618
|
+
ReadinessMethodType.Telegram,
|
|
2619
|
+
ReadinessMethodType.Webhook,
|
|
2620
|
+
]);
|
|
2621
|
+
});
|
|
2622
|
+
|
|
2623
|
+
test("methods belonging to another responder are not attributed to this one", async () => {
|
|
2624
|
+
attachDirectly(USER_A_ID, USER_B_ID);
|
|
2625
|
+
pushFindBy.mockResolvedValue([
|
|
2626
|
+
pushMethod({ userId: USER_B_ID, isVerified: true }),
|
|
2627
|
+
] as never);
|
|
2628
|
+
|
|
2629
|
+
const summary: ReadinessSummary = await policySummary();
|
|
2630
|
+
const ada: UserReadiness = summary.users.find(
|
|
2631
|
+
(user: UserReadiness): boolean => {
|
|
2632
|
+
return user.userId.toString() === USER_A_ID.toString();
|
|
2633
|
+
},
|
|
2634
|
+
)!;
|
|
2635
|
+
|
|
2636
|
+
expect(ada.methods).toEqual([]);
|
|
2637
|
+
expect(ada.status).toBe(ReadinessStatus.NotReachable);
|
|
2638
|
+
});
|
|
2639
|
+
|
|
2640
|
+
describe("fallback prose", () => {
|
|
2641
|
+
beforeEach(() => {
|
|
2642
|
+
setSeverities({
|
|
2643
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
2644
|
+
});
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
test("both zero-cost channels are named when the responder has both", async () => {
|
|
2648
|
+
pushFindBy.mockResolvedValue([
|
|
2649
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
2650
|
+
] as never);
|
|
2651
|
+
emailFindBy.mockResolvedValue([
|
|
2652
|
+
emailMethod({
|
|
2653
|
+
userId: USER_A_ID,
|
|
2654
|
+
email: RAW_NOTIFICATION_EMAIL,
|
|
2655
|
+
isVerified: true,
|
|
2656
|
+
}),
|
|
2657
|
+
] as never);
|
|
2658
|
+
|
|
2659
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2660
|
+
|
|
2661
|
+
expect(readiness.reasons[0]).toBe(
|
|
2662
|
+
"No rules for Sev1 incidents - pages fall back to Push, Email",
|
|
2663
|
+
);
|
|
2664
|
+
});
|
|
2665
|
+
|
|
2666
|
+
test("a paid channel is named only while the project still has it switched on", async () => {
|
|
2667
|
+
smsFindBy.mockResolvedValue([
|
|
2668
|
+
smsMethod({
|
|
2669
|
+
userId: USER_A_ID,
|
|
2670
|
+
phone: RAW_SMS_PHONE,
|
|
2671
|
+
isVerified: true,
|
|
2672
|
+
}),
|
|
2673
|
+
] as never);
|
|
2674
|
+
|
|
2675
|
+
expect((await onlyUser()).reasons[0]).toBe(
|
|
2676
|
+
"No rules for Sev1 incidents - pages fall back to SMS",
|
|
2677
|
+
);
|
|
2678
|
+
});
|
|
2679
|
+
|
|
2680
|
+
test("a responder whose only channels the project has switched off is NotReachable, not amber", async () => {
|
|
2681
|
+
projectFindOneById.mockResolvedValue(
|
|
2682
|
+
makeProject({ enableTelegramNotifications: false }) as never,
|
|
2683
|
+
);
|
|
2684
|
+
telegramFindBy.mockResolvedValue([
|
|
2685
|
+
telegramMethod({
|
|
2686
|
+
userId: USER_A_ID,
|
|
2687
|
+
handle: RAW_TELEGRAM_HANDLE,
|
|
2688
|
+
isVerified: true,
|
|
2689
|
+
}),
|
|
2690
|
+
] as never);
|
|
2691
|
+
|
|
2692
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2693
|
+
|
|
2694
|
+
/*
|
|
2695
|
+
* Nothing can reach this person. Their method is verified, so a status
|
|
2696
|
+
* computed from verification alone would call them Ready - and Ready is a
|
|
2697
|
+
* green chip next to a responder no page will ever arrive at. Verified is
|
|
2698
|
+
* necessary and not sufficient; usable is verified AND on a channel the
|
|
2699
|
+
* project actually sends on.
|
|
2700
|
+
*/
|
|
2701
|
+
expect(readiness.status).toBe(ReadinessStatus.NotReachable);
|
|
2702
|
+
expect(readiness.reasons[0]).toBe(
|
|
2703
|
+
"No usable notification method - cannot be paged",
|
|
2704
|
+
);
|
|
2705
|
+
expect(readiness.reasons[1]).toBe(
|
|
2706
|
+
"Every method they have verified is on Telegram, and this project has that channel switched off - that is a project setting, not something this user can fix",
|
|
2707
|
+
);
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
test("with the fallback disabled, an uncovered cell means the page is DROPPED, not delayed", async () => {
|
|
2711
|
+
projectFindOneById.mockResolvedValue(
|
|
2712
|
+
makeProject({ disableOnCallNotificationFallback: true }) as never,
|
|
2713
|
+
);
|
|
2714
|
+
pushFindBy.mockResolvedValue([
|
|
2715
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
2716
|
+
] as never);
|
|
2717
|
+
|
|
2718
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2719
|
+
|
|
2720
|
+
expect(readiness.reasons[0]).toBe(
|
|
2721
|
+
"No rules for Sev1 incidents - pages are dropped because on-call fallback is disabled for this project",
|
|
2722
|
+
);
|
|
2723
|
+
expect(
|
|
2724
|
+
readiness.reasons.some((reason: string): boolean => {
|
|
2725
|
+
return reason.includes("switched off");
|
|
2726
|
+
}),
|
|
2727
|
+
).toBe(false);
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
test("a missing project row is treated as every paid channel off, never as every channel on", async () => {
|
|
2731
|
+
projectFindOneById.mockResolvedValue(null as never);
|
|
2732
|
+
smsFindBy.mockResolvedValue([
|
|
2733
|
+
smsMethod({
|
|
2734
|
+
userId: USER_A_ID,
|
|
2735
|
+
phone: RAW_SMS_PHONE,
|
|
2736
|
+
isVerified: true,
|
|
2737
|
+
}),
|
|
2738
|
+
] as never);
|
|
2739
|
+
|
|
2740
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2741
|
+
|
|
2742
|
+
/*
|
|
2743
|
+
* A project row we could not read must never certify anyone as reachable
|
|
2744
|
+
* on a paid channel. Defaulting the switches to "on" would turn a failed
|
|
2745
|
+
* read into a green chip, which is the one direction this service is not
|
|
2746
|
+
* allowed to be wrong in.
|
|
2747
|
+
*/
|
|
2748
|
+
expect(readiness.status).toBe(ReadinessStatus.NotReachable);
|
|
2749
|
+
expect(readiness.reasons[1]).toContain("switched off");
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2752
|
+
test("one sentence per rule type, with its missing severities listed rather than one line per cell", async () => {
|
|
2753
|
+
setSeverities({
|
|
2754
|
+
incident: [
|
|
2755
|
+
incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1"),
|
|
2756
|
+
incidentSeverity(INCIDENT_SEVERITY_2_ID, "Sev2"),
|
|
2757
|
+
],
|
|
2758
|
+
alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
|
|
2759
|
+
});
|
|
2760
|
+
pushFindBy.mockResolvedValue([
|
|
2761
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
2762
|
+
] as never);
|
|
2763
|
+
|
|
2764
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2765
|
+
|
|
2766
|
+
expect(readiness.coverage).toHaveLength(6);
|
|
2767
|
+
expect(readiness.reasons).toEqual([
|
|
2768
|
+
"No rules for Sev1, Sev2 incidents - pages fall back to Push",
|
|
2769
|
+
"No rules for Sev1, Sev2 incident episodes - pages fall back to Push",
|
|
2770
|
+
"No rules for Warning alerts - pages fall back to Push",
|
|
2771
|
+
"No rules for Warning alert episodes - pages fall back to Push",
|
|
2772
|
+
]);
|
|
2773
|
+
});
|
|
2774
|
+
});
|
|
2775
|
+
});
|
|
2776
|
+
|
|
2777
|
+
/*
|
|
2778
|
+
* ---------------------------------------------------------------------------
|
|
2779
|
+
* (G2) Method identity - referencing a method without reading it.
|
|
2780
|
+
*
|
|
2781
|
+
* The admin rule form has to POINT A RULE AT one of these methods, and it is not
|
|
2782
|
+
* allowed to read the row it points at: the seven method models are scoped to
|
|
2783
|
+
* their owner precisely because their columns are the raw phone number, the
|
|
2784
|
+
* webhook bearer url, the push device token, the telegram chat id and the
|
|
2785
|
+
* verification code. Widening that scope so a dropdown could be populated was
|
|
2786
|
+
* tried, and the exposure it opened could not be contained.
|
|
2787
|
+
*
|
|
2788
|
+
* `methodId` is what replaces it. The foreign key is not a secret - it is
|
|
2789
|
+
* already stored in plain sight on every rule its owner created, and it
|
|
2790
|
+
* addresses nothing on its own - so shipping it beside the mask is what lets an
|
|
2791
|
+
* admin select "SMS ending 4821" with the number itself never leaving the
|
|
2792
|
+
* server.
|
|
2793
|
+
*
|
|
2794
|
+
* Which makes these tests load-bearing in two directions at once: the id must be
|
|
2795
|
+
* PRESENT (or the form has nothing to submit) and it must be the id of the
|
|
2796
|
+
* METHOD row (or the form submits a userSmsId that is not a UserSMS).
|
|
2797
|
+
* ---------------------------------------------------------------------------
|
|
2798
|
+
*/
|
|
2799
|
+
describe("method identity", () => {
|
|
2800
|
+
beforeEach(() => {
|
|
2801
|
+
attachDirectly(USER_A_ID);
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
function attachEverySevenChannels(): void {
|
|
2805
|
+
pushFindBy.mockResolvedValue([
|
|
2806
|
+
pushMethod({
|
|
2807
|
+
userId: USER_A_ID,
|
|
2808
|
+
deviceName: RAW_PUSH_DEVICE,
|
|
2809
|
+
isVerified: true,
|
|
2810
|
+
id: PUSH_METHOD_ID,
|
|
2811
|
+
}),
|
|
2812
|
+
] as never);
|
|
2813
|
+
emailFindBy.mockResolvedValue([
|
|
2814
|
+
emailMethod({
|
|
2815
|
+
userId: USER_A_ID,
|
|
2816
|
+
email: RAW_NOTIFICATION_EMAIL,
|
|
2817
|
+
isVerified: true,
|
|
2818
|
+
id: EMAIL_METHOD_ID,
|
|
2819
|
+
}),
|
|
2820
|
+
] as never);
|
|
2821
|
+
smsFindBy.mockResolvedValue([
|
|
2822
|
+
smsMethod({
|
|
2823
|
+
userId: USER_A_ID,
|
|
2824
|
+
phone: RAW_SMS_PHONE,
|
|
2825
|
+
isVerified: true,
|
|
2826
|
+
id: SMS_METHOD_ID,
|
|
2827
|
+
}),
|
|
2828
|
+
] as never);
|
|
2829
|
+
callFindBy.mockResolvedValue([
|
|
2830
|
+
callMethod({
|
|
2831
|
+
userId: USER_A_ID,
|
|
2832
|
+
phone: RAW_CALL_PHONE,
|
|
2833
|
+
isVerified: true,
|
|
2834
|
+
id: CALL_METHOD_ID,
|
|
2835
|
+
}),
|
|
2836
|
+
] as never);
|
|
2837
|
+
whatsAppFindBy.mockResolvedValue([
|
|
2838
|
+
whatsAppMethod({
|
|
2839
|
+
userId: USER_A_ID,
|
|
2840
|
+
phone: RAW_WHATSAPP_PHONE,
|
|
2841
|
+
isVerified: true,
|
|
2842
|
+
id: WHATSAPP_METHOD_ID,
|
|
2843
|
+
}),
|
|
2844
|
+
] as never);
|
|
2845
|
+
telegramFindBy.mockResolvedValue([
|
|
2846
|
+
telegramMethod({
|
|
2847
|
+
userId: USER_A_ID,
|
|
2848
|
+
handle: RAW_TELEGRAM_HANDLE,
|
|
2849
|
+
isVerified: true,
|
|
2850
|
+
id: TELEGRAM_METHOD_ID,
|
|
2851
|
+
}),
|
|
2852
|
+
] as never);
|
|
2853
|
+
webhookFindBy.mockResolvedValue([
|
|
2854
|
+
webhookMethod({
|
|
2855
|
+
userId: USER_A_ID,
|
|
2856
|
+
name: RAW_WEBHOOK_NAME,
|
|
2857
|
+
webhookUrl: RAW_WEBHOOK_URL,
|
|
2858
|
+
id: WEBHOOK_METHOD_ID,
|
|
2859
|
+
}),
|
|
2860
|
+
] as never);
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
test("all seven channels carry the id of their OWN row, which is what a rule references", async () => {
|
|
2864
|
+
attachEverySevenChannels();
|
|
2865
|
+
|
|
2866
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2867
|
+
|
|
2868
|
+
/*
|
|
2869
|
+
* One expectation per channel rather than a loop, so a failure names the
|
|
2870
|
+
* channel that lost its id rather than an index into an array.
|
|
2871
|
+
*/
|
|
2872
|
+
expect(
|
|
2873
|
+
methodOfType(readiness, ReadinessMethodType.Push).methodId.toString(),
|
|
2874
|
+
).toBe(PUSH_METHOD_ID.toString());
|
|
2875
|
+
expect(
|
|
2876
|
+
methodOfType(readiness, ReadinessMethodType.Email).methodId.toString(),
|
|
2877
|
+
).toBe(EMAIL_METHOD_ID.toString());
|
|
2878
|
+
expect(
|
|
2879
|
+
methodOfType(readiness, ReadinessMethodType.SMS).methodId.toString(),
|
|
2880
|
+
).toBe(SMS_METHOD_ID.toString());
|
|
2881
|
+
expect(
|
|
2882
|
+
methodOfType(readiness, ReadinessMethodType.Call).methodId.toString(),
|
|
2883
|
+
).toBe(CALL_METHOD_ID.toString());
|
|
2884
|
+
expect(
|
|
2885
|
+
methodOfType(readiness, ReadinessMethodType.WhatsApp).methodId.toString(),
|
|
2886
|
+
).toBe(WHATSAPP_METHOD_ID.toString());
|
|
2887
|
+
expect(
|
|
2888
|
+
methodOfType(readiness, ReadinessMethodType.Telegram).methodId.toString(),
|
|
2889
|
+
).toBe(TELEGRAM_METHOD_ID.toString());
|
|
2890
|
+
expect(
|
|
2891
|
+
methodOfType(readiness, ReadinessMethodType.Webhook).methodId.toString(),
|
|
2892
|
+
).toBe(WEBHOOK_METHOD_ID.toString());
|
|
2893
|
+
});
|
|
2894
|
+
|
|
2895
|
+
test("no methodId is the USER's id - a rule pointed at a user id points at no method at all", async () => {
|
|
2896
|
+
attachEverySevenChannels();
|
|
2897
|
+
|
|
2898
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2899
|
+
|
|
2900
|
+
expect(readiness.methods).toHaveLength(7);
|
|
2901
|
+
|
|
2902
|
+
for (const method of readiness.methods) {
|
|
2903
|
+
/*
|
|
2904
|
+
* `row.userId` and `row.id` are both ObjectIDs on the same row, so
|
|
2905
|
+
* confusing them compiles, renders and reads correctly right up until the
|
|
2906
|
+
* saved rule turns out to reference a User rather than a UserSMS. This is
|
|
2907
|
+
* the assertion that separates them.
|
|
2908
|
+
*/
|
|
2909
|
+
expect(method.methodId.toString()).not.toBe(USER_A_ID.toString());
|
|
2910
|
+
}
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2913
|
+
test("two methods on the SAME channel are told apart by their ids, which is the whole point of a dropdown", async () => {
|
|
2914
|
+
/*
|
|
2915
|
+
* The case the admin form exists for: a responder with a work phone and a
|
|
2916
|
+
* personal phone. Both mask to something ending in four digits, and the
|
|
2917
|
+
* masks are all the admin can see, so the id is the only thing that makes
|
|
2918
|
+
* "the second one" selectable.
|
|
2919
|
+
*/
|
|
2920
|
+
const secondSmsId: ObjectID = new ObjectID(
|
|
2921
|
+
"c0000000-0000-4000-8000-00000000000a",
|
|
2922
|
+
);
|
|
2923
|
+
|
|
2924
|
+
smsFindBy.mockResolvedValue([
|
|
2925
|
+
smsMethod({
|
|
2926
|
+
userId: USER_A_ID,
|
|
2927
|
+
phone: RAW_SMS_PHONE,
|
|
2928
|
+
isVerified: true,
|
|
2929
|
+
id: SMS_METHOD_ID,
|
|
2930
|
+
}),
|
|
2931
|
+
smsMethod({
|
|
2932
|
+
userId: USER_A_ID,
|
|
2933
|
+
phone: "+14155550000",
|
|
2934
|
+
isVerified: true,
|
|
2935
|
+
id: secondSmsId,
|
|
2936
|
+
}),
|
|
2937
|
+
] as never);
|
|
2938
|
+
|
|
2939
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2940
|
+
const smsMethods: Array<ReadinessMethod> = readiness.methods.filter(
|
|
2941
|
+
(method: ReadinessMethod): boolean => {
|
|
2942
|
+
return method.methodType === ReadinessMethodType.SMS;
|
|
2943
|
+
},
|
|
2944
|
+
);
|
|
2945
|
+
|
|
2946
|
+
expect(smsMethods).toHaveLength(2);
|
|
2947
|
+
expect(
|
|
2948
|
+
smsMethods.map((method: ReadinessMethod): string => {
|
|
2949
|
+
return method.methodId.toString();
|
|
2950
|
+
}),
|
|
2951
|
+
).toEqual([SMS_METHOD_ID.toString(), secondSmsId.toString()]);
|
|
2952
|
+
});
|
|
2953
|
+
|
|
2954
|
+
test("one responder's method id is never attached to another responder's mask", async () => {
|
|
2955
|
+
attachDirectly(USER_A_ID, USER_B_ID);
|
|
2956
|
+
smsFindBy.mockResolvedValue([
|
|
2957
|
+
smsMethod({
|
|
2958
|
+
userId: USER_B_ID,
|
|
2959
|
+
phone: RAW_SMS_PHONE,
|
|
2960
|
+
isVerified: true,
|
|
2961
|
+
id: SMS_METHOD_ID,
|
|
2962
|
+
}),
|
|
2963
|
+
] as never);
|
|
2964
|
+
|
|
2965
|
+
const summary: ReadinessSummary = await policySummary();
|
|
2966
|
+
const ada: UserReadiness = summary.users.find(
|
|
2967
|
+
(user: UserReadiness): boolean => {
|
|
2968
|
+
return user.userId.toString() === USER_A_ID.toString();
|
|
2969
|
+
},
|
|
2970
|
+
)!;
|
|
2971
|
+
const grace: UserReadiness = summary.users.find(
|
|
2972
|
+
(user: UserReadiness): boolean => {
|
|
2973
|
+
return user.userId.toString() === USER_B_ID.toString();
|
|
2974
|
+
},
|
|
2975
|
+
)!;
|
|
2976
|
+
|
|
2977
|
+
expect(ada.methods).toEqual([]);
|
|
2978
|
+
expect(
|
|
2979
|
+
methodOfType(grace, ReadinessMethodType.SMS).methodId.toString(),
|
|
2980
|
+
).toBe(SMS_METHOD_ID.toString());
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
test("a row that arrives with no id at all is dropped, not emitted with a hole where its id should be", async () => {
|
|
2984
|
+
/*
|
|
2985
|
+
* Unreachable while every select asks for `_id`, and pinned anyway: the
|
|
2986
|
+
* alternative to dropping is an option in the rule form that cannot be
|
|
2987
|
+
* submitted, or a `methodId` typed optional so that every consumer has to
|
|
2988
|
+
* handle a case that cannot happen. Dropping errs towards reporting the
|
|
2989
|
+
* responder as LESS reachable than they are, which is the direction this
|
|
2990
|
+
* service always errs in.
|
|
2991
|
+
*/
|
|
2992
|
+
const idless: UserPush = new UserPush();
|
|
2993
|
+
idless.userId = USER_A_ID;
|
|
2994
|
+
idless.isVerified = true;
|
|
2995
|
+
|
|
2996
|
+
pushFindBy.mockResolvedValue([idless] as never);
|
|
2997
|
+
|
|
2998
|
+
const readiness: UserReadiness = await onlyUser();
|
|
2999
|
+
|
|
3000
|
+
expect(readiness.methods).toEqual([]);
|
|
3001
|
+
expect(readiness.status).toBe(ReadinessStatus.NotReachable);
|
|
3002
|
+
});
|
|
3003
|
+
});
|
|
3004
|
+
|
|
3005
|
+
/*
|
|
3006
|
+
* ---------------------------------------------------------------------------
|
|
3007
|
+
* (H) Masking, end to end.
|
|
3008
|
+
*
|
|
3009
|
+
* The assertions here run over the SERIALIZED summary rather than the fields a
|
|
3010
|
+
* test author remembered to check, because a leak that arrives through a field
|
|
3011
|
+
* nobody listed is exactly the leak that ships.
|
|
3012
|
+
* ---------------------------------------------------------------------------
|
|
3013
|
+
*/
|
|
3014
|
+
describe("identifier exposure", () => {
|
|
3015
|
+
beforeEach(() => {
|
|
3016
|
+
attachDirectly(USER_A_ID);
|
|
3017
|
+
pushFindBy.mockResolvedValue([
|
|
3018
|
+
pushMethod({
|
|
3019
|
+
userId: USER_A_ID,
|
|
3020
|
+
deviceName: RAW_PUSH_DEVICE,
|
|
3021
|
+
isVerified: true,
|
|
3022
|
+
}),
|
|
3023
|
+
] as never);
|
|
3024
|
+
emailFindBy.mockResolvedValue([
|
|
3025
|
+
emailMethod({
|
|
3026
|
+
userId: USER_A_ID,
|
|
3027
|
+
email: RAW_NOTIFICATION_EMAIL,
|
|
3028
|
+
isVerified: true,
|
|
3029
|
+
}),
|
|
3030
|
+
] as never);
|
|
3031
|
+
smsFindBy.mockResolvedValue([
|
|
3032
|
+
smsMethod({ userId: USER_A_ID, phone: RAW_SMS_PHONE, isVerified: true }),
|
|
3033
|
+
] as never);
|
|
3034
|
+
callFindBy.mockResolvedValue([
|
|
3035
|
+
callMethod({
|
|
3036
|
+
userId: USER_A_ID,
|
|
3037
|
+
phone: RAW_CALL_PHONE,
|
|
3038
|
+
isVerified: true,
|
|
3039
|
+
}),
|
|
3040
|
+
] as never);
|
|
3041
|
+
whatsAppFindBy.mockResolvedValue([
|
|
3042
|
+
whatsAppMethod({
|
|
3043
|
+
userId: USER_A_ID,
|
|
3044
|
+
phone: RAW_WHATSAPP_PHONE,
|
|
3045
|
+
isVerified: true,
|
|
3046
|
+
}),
|
|
3047
|
+
] as never);
|
|
3048
|
+
telegramFindBy.mockResolvedValue([
|
|
3049
|
+
telegramMethod({
|
|
3050
|
+
userId: USER_A_ID,
|
|
3051
|
+
handle: RAW_TELEGRAM_HANDLE,
|
|
3052
|
+
isVerified: true,
|
|
3053
|
+
}),
|
|
3054
|
+
] as never);
|
|
3055
|
+
webhookFindBy.mockResolvedValue([
|
|
3056
|
+
webhookMethod({
|
|
3057
|
+
userId: USER_A_ID,
|
|
3058
|
+
name: RAW_WEBHOOK_NAME,
|
|
3059
|
+
webhookUrl: RAW_WEBHOOK_URL,
|
|
3060
|
+
}),
|
|
3061
|
+
] as never);
|
|
3062
|
+
});
|
|
3063
|
+
|
|
3064
|
+
test("every emitted identifier is masked, one shape at a time", async () => {
|
|
3065
|
+
const readiness: UserReadiness = await onlyUser();
|
|
3066
|
+
const masked: Map<string, string> = new Map<string, string>(
|
|
3067
|
+
readiness.methods.map((method: ReadinessMethod): [string, string] => {
|
|
3068
|
+
return [method.methodType, method.maskedIdentifier];
|
|
3069
|
+
}),
|
|
3070
|
+
);
|
|
3071
|
+
|
|
3072
|
+
expect(masked.get(ReadinessMethodType.Push)).toBe(`Ad${IDENTIFIER_MASK}`);
|
|
3073
|
+
expect(masked.get(ReadinessMethodType.Email)).toBe(
|
|
3074
|
+
`a${IDENTIFIER_MASK}@analytical-engine.example`,
|
|
3075
|
+
);
|
|
3076
|
+
expect(masked.get(ReadinessMethodType.SMS)).toBe(
|
|
3077
|
+
`+1 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`,
|
|
3078
|
+
);
|
|
3079
|
+
expect(masked.get(ReadinessMethodType.Call)).toBe(
|
|
3080
|
+
`+44 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 8750`,
|
|
3081
|
+
);
|
|
3082
|
+
/*
|
|
3083
|
+
* "+6", not "+61" - see the heuristic test above. An eleven-digit
|
|
3084
|
+
* Australian number splits one digit early, which hides more than it
|
|
3085
|
+
* promises rather than less.
|
|
3086
|
+
*/
|
|
3087
|
+
expect(masked.get(ReadinessMethodType.WhatsApp)).toBe(
|
|
3088
|
+
`+6 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4000`,
|
|
3089
|
+
);
|
|
3090
|
+
expect(masked.get(ReadinessMethodType.Telegram)).toBe(
|
|
3091
|
+
`@ad${IDENTIFIER_MASK}`,
|
|
3092
|
+
);
|
|
3093
|
+
expect(masked.get(ReadinessMethodType.Webhook)).toBe(
|
|
3094
|
+
`Pa${IDENTIFIER_MASK}`,
|
|
3095
|
+
);
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
test("a method carries FOUR fields and no fifth - the id is the ONLY thing beside the mask", async () => {
|
|
3099
|
+
const readiness: UserReadiness = await onlyUser();
|
|
3100
|
+
|
|
3101
|
+
expect(readiness.methods).toHaveLength(7);
|
|
3102
|
+
|
|
3103
|
+
/*
|
|
3104
|
+
* An exact key set, not a "does not contain the url" check, and this is the
|
|
3105
|
+
* strongest assertion in the file. Every field on a ReadinessMethod ships to
|
|
3106
|
+
* every administrator of the project, so the question is never "is this
|
|
3107
|
+
* particular new field safe" but "did anyone add a field at all" - the raw
|
|
3108
|
+
* phone number, the webhook bearer url, the push device token, the telegram
|
|
3109
|
+
* chat id and the verification code all live one property away on the row
|
|
3110
|
+
* these are built from. `methodId` was added deliberately, because a foreign
|
|
3111
|
+
* key addresses nothing on its own; a fifth field has to argue its way
|
|
3112
|
+
* through this test.
|
|
3113
|
+
*/
|
|
3114
|
+
for (const method of readiness.methods) {
|
|
3115
|
+
expect(Object.keys(method).sort()).toEqual([
|
|
3116
|
+
"isVerified",
|
|
3117
|
+
"maskedIdentifier",
|
|
3118
|
+
"methodId",
|
|
3119
|
+
"methodType",
|
|
3120
|
+
]);
|
|
3121
|
+
}
|
|
3122
|
+
});
|
|
3123
|
+
|
|
3124
|
+
test("NO raw identifier survives anywhere in the serialized summary", async () => {
|
|
3125
|
+
const summary: ReadinessSummary = await policySummary();
|
|
3126
|
+
const serialized: string = JSON.stringify(summary);
|
|
3127
|
+
|
|
3128
|
+
/*
|
|
3129
|
+
* The ids ARE on the wire now - that is what makes an admin dropdown
|
|
3130
|
+
* possible without a cross-user read - so this assertion is the one that
|
|
3131
|
+
* says the id is all that was added. It runs over the serialized summary
|
|
3132
|
+
* rather than over the fields listed above precisely because a leak that
|
|
3133
|
+
* arrives through a field nobody listed is the leak that ships.
|
|
3134
|
+
*/
|
|
3135
|
+
expect(serialized).toContain("methodId");
|
|
3136
|
+
|
|
3137
|
+
/*
|
|
3138
|
+
* Whole values first, then the "revealing middle" of each - the part that
|
|
3139
|
+
* no legitimate mask can keep - so a partial leak through some future field
|
|
3140
|
+
* cannot pass by having merely reordered the string.
|
|
3141
|
+
*/
|
|
3142
|
+
for (const raw of [
|
|
3143
|
+
RAW_NOTIFICATION_EMAIL,
|
|
3144
|
+
RAW_SMS_PHONE,
|
|
3145
|
+
RAW_CALL_PHONE,
|
|
3146
|
+
RAW_WHATSAPP_PHONE,
|
|
3147
|
+
RAW_TELEGRAM_HANDLE,
|
|
3148
|
+
RAW_WEBHOOK_NAME,
|
|
3149
|
+
RAW_WEBHOOK_URL,
|
|
3150
|
+
RAW_PUSH_DEVICE,
|
|
3151
|
+
]) {
|
|
3152
|
+
expect(serialized).not.toContain(raw);
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
for (const fragment of [
|
|
3156
|
+
"ada.lovelace",
|
|
3157
|
+
"4155554",
|
|
3158
|
+
"2071838",
|
|
3159
|
+
"6129374",
|
|
3160
|
+
"night_pager",
|
|
3161
|
+
"Incident Bridge",
|
|
3162
|
+
"SUPERSECRETTOKEN",
|
|
3163
|
+
"iPhone",
|
|
3164
|
+
]) {
|
|
3165
|
+
expect(serialized).not.toContain(fragment);
|
|
3166
|
+
}
|
|
3167
|
+
});
|
|
3168
|
+
|
|
3169
|
+
test("the LOGIN email is deliberately NOT masked - it is already admin-readable everywhere else", async () => {
|
|
3170
|
+
const summary: ReadinessSummary = await policySummary();
|
|
3171
|
+
|
|
3172
|
+
expect(summary.users[0]!.userEmail).toBe(USER_A_LOGIN_EMAIL);
|
|
3173
|
+
expect(JSON.stringify(summary)).toContain(USER_A_LOGIN_EMAIL);
|
|
3174
|
+
});
|
|
3175
|
+
|
|
3176
|
+
test("the webhook read never asks for the bearer url", async () => {
|
|
3177
|
+
await policySummary();
|
|
3178
|
+
|
|
3179
|
+
const select: Record<string, unknown> = firstCall(webhookFindBy).select!;
|
|
3180
|
+
|
|
3181
|
+
/*
|
|
3182
|
+
* UserWebhook.webhookUrl is a bearer credential - anyone holding a
|
|
3183
|
+
* Slack/Discord/Teams hook url can post as the integration - so it must
|
|
3184
|
+
* never leave the server on this path. Asserting on the exact select rather
|
|
3185
|
+
* than on absence alone means a widened select is a failure even if the new
|
|
3186
|
+
* column is not itself the url.
|
|
3187
|
+
*/
|
|
3188
|
+
expect(select).toEqual({ _id: true, userId: true, name: true });
|
|
3189
|
+
expect(Object.keys(select)).not.toContain("webhookUrl");
|
|
3190
|
+
expect(Object.keys(select)).not.toContain("url");
|
|
3191
|
+
expect(Object.keys(select)).not.toContain("secret");
|
|
3192
|
+
});
|
|
3193
|
+
|
|
3194
|
+
test("the telegram read never asks for the chat id, which is the addressable target", async () => {
|
|
3195
|
+
await policySummary();
|
|
3196
|
+
|
|
3197
|
+
const select: Record<string, unknown> = firstCall(telegramFindBy).select!;
|
|
3198
|
+
|
|
3199
|
+
expect(Object.keys(select)).not.toContain("telegramChatId");
|
|
3200
|
+
expect(select["telegramUserHandle"]).toBe(true);
|
|
3201
|
+
});
|
|
3202
|
+
|
|
3203
|
+
test("a method with no identifier at all still reports the bare mask, not an empty cell", async () => {
|
|
3204
|
+
pushFindBy.mockResolvedValue([
|
|
3205
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
3206
|
+
] as never);
|
|
3207
|
+
|
|
3208
|
+
const readiness: UserReadiness = await onlyUser();
|
|
3209
|
+
const push: ReadinessMethod = readiness.methods.find(
|
|
3210
|
+
(method: ReadinessMethod): boolean => {
|
|
3211
|
+
return method.methodType === ReadinessMethodType.Push;
|
|
3212
|
+
},
|
|
3213
|
+
)!;
|
|
3214
|
+
|
|
3215
|
+
expect(push.maskedIdentifier).toBe(IDENTIFIER_MASK);
|
|
3216
|
+
});
|
|
3217
|
+
});
|
|
3218
|
+
|
|
3219
|
+
/*
|
|
3220
|
+
* ---------------------------------------------------------------------------
|
|
3221
|
+
* (I) Batching and paging - the N+1 and truncation regression guards.
|
|
3222
|
+
* ---------------------------------------------------------------------------
|
|
3223
|
+
*/
|
|
3224
|
+
describe("batching and paging", () => {
|
|
3225
|
+
function manyUsers(count: number): Array<ObjectID> {
|
|
3226
|
+
const ids: Array<ObjectID> = [];
|
|
3227
|
+
|
|
3228
|
+
for (let index: number = 0; index < count; index++) {
|
|
3229
|
+
ids.push(
|
|
3230
|
+
new ObjectID(
|
|
3231
|
+
`c0ffee00-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
|
3232
|
+
),
|
|
3233
|
+
);
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
return ids;
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
test("the query count does not grow with the number of responders OR severities", async () => {
|
|
3240
|
+
attachDirectly(USER_A_ID, USER_B_ID);
|
|
3241
|
+
setSeverities({
|
|
3242
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
3243
|
+
alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
|
|
3244
|
+
});
|
|
3245
|
+
|
|
3246
|
+
const small: ReadinessSummary = await policySummary();
|
|
3247
|
+
const smallQueryCount: number = totalQueryCount();
|
|
3248
|
+
|
|
3249
|
+
expect(small.users).toHaveLength(2);
|
|
3250
|
+
|
|
3251
|
+
jest.clearAllMocks();
|
|
3252
|
+
OnCallReadinessService.clearCache();
|
|
3253
|
+
|
|
3254
|
+
const ids: Array<ObjectID> = manyUsers(12);
|
|
3255
|
+
userDirectory = ids.map((id: ObjectID, index: number): User => {
|
|
3256
|
+
return makeUser(
|
|
3257
|
+
id,
|
|
3258
|
+
`Responder ${index}`,
|
|
3259
|
+
`responder${index}@corp.example.com`,
|
|
3260
|
+
);
|
|
3261
|
+
});
|
|
3262
|
+
attachDirectly(...ids);
|
|
3263
|
+
setSeverities({
|
|
3264
|
+
incident: [
|
|
3265
|
+
incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1"),
|
|
3266
|
+
incidentSeverity(INCIDENT_SEVERITY_2_ID, "Sev2"),
|
|
3267
|
+
incidentSeverity(
|
|
3268
|
+
new ObjectID("d0d0d0d0-0000-4000-8000-000000000001"),
|
|
3269
|
+
"Sev3",
|
|
3270
|
+
),
|
|
3271
|
+
incidentSeverity(
|
|
3272
|
+
new ObjectID("d0d0d0d0-0000-4000-8000-000000000002"),
|
|
3273
|
+
"Sev4",
|
|
3274
|
+
),
|
|
3275
|
+
],
|
|
3276
|
+
alert: [
|
|
3277
|
+
alertSeverity(ALERT_SEVERITY_1_ID, "Warning"),
|
|
3278
|
+
alertSeverity(ALERT_SEVERITY_2_ID, "Critical"),
|
|
3279
|
+
alertSeverity(
|
|
3280
|
+
new ObjectID("e0e0e0e0-0000-4000-8000-000000000001"),
|
|
3281
|
+
"Info",
|
|
3282
|
+
),
|
|
3283
|
+
],
|
|
3284
|
+
});
|
|
3285
|
+
|
|
3286
|
+
const large: ReadinessSummary = await policySummary();
|
|
3287
|
+
|
|
3288
|
+
expect(large.users).toHaveLength(12);
|
|
3289
|
+
expect(large.users[0]!.coverage).toHaveLength(14);
|
|
3290
|
+
|
|
3291
|
+
/*
|
|
3292
|
+
* Six times the responders and seven times the severities, and not one
|
|
3293
|
+
* extra round trip. TeamComplianceService ran a findBy per severity per
|
|
3294
|
+
* user, which is the shape this asserts can never come back.
|
|
3295
|
+
*/
|
|
3296
|
+
expect(totalQueryCount()).toBe(smallQueryCount);
|
|
3297
|
+
});
|
|
3298
|
+
|
|
3299
|
+
test("no service is asked anything more than once for a single summary", async () => {
|
|
3300
|
+
attachDirectly(USER_A_ID, USER_B_ID, USER_C_ID);
|
|
3301
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
3302
|
+
escalationTeamRow(TEAM_ID),
|
|
3303
|
+
escalationTeamRow(OTHER_TEAM_ID),
|
|
3304
|
+
] as never);
|
|
3305
|
+
teamMemberRows = [teamMemberRow(USER_A_ID), teamMemberRow(USER_B_ID)];
|
|
3306
|
+
escalationScheduleFindBy.mockResolvedValue([
|
|
3307
|
+
escalationScheduleRow(SCHEDULE_ID),
|
|
3308
|
+
] as never);
|
|
3309
|
+
scheduleLayerUserFindBy.mockResolvedValue([
|
|
3310
|
+
layerUserRow(USER_C_ID),
|
|
3311
|
+
] as never);
|
|
3312
|
+
setSeverities({
|
|
3313
|
+
incident: [
|
|
3314
|
+
incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1"),
|
|
3315
|
+
incidentSeverity(INCIDENT_SEVERITY_2_ID, "Sev2"),
|
|
3316
|
+
],
|
|
3317
|
+
alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
|
|
3318
|
+
});
|
|
3319
|
+
|
|
3320
|
+
await policySummary();
|
|
3321
|
+
|
|
3322
|
+
for (const spy of everySpy()) {
|
|
3323
|
+
expect(spy.mock.calls.length).toBeLessThanOrEqual(1);
|
|
3324
|
+
}
|
|
3325
|
+
});
|
|
3326
|
+
|
|
3327
|
+
test("the per-user reads are ONE query each over the whole responder set", async () => {
|
|
3328
|
+
const ids: Array<ObjectID> = manyUsers(5);
|
|
3329
|
+
userDirectory = ids.map((id: ObjectID, index: number): User => {
|
|
3330
|
+
return makeUser(
|
|
3331
|
+
id,
|
|
3332
|
+
`Responder ${index}`,
|
|
3333
|
+
`responder${index}@corp.example.com`,
|
|
3334
|
+
);
|
|
3335
|
+
});
|
|
3336
|
+
attachDirectly(...ids);
|
|
3337
|
+
|
|
3338
|
+
await policySummary();
|
|
3339
|
+
|
|
3340
|
+
const expected: Array<string> = ids.map((id: ObjectID): string => {
|
|
3341
|
+
return id.toString();
|
|
3342
|
+
});
|
|
3343
|
+
|
|
3344
|
+
for (const spy of [
|
|
3345
|
+
userFindBy,
|
|
3346
|
+
pushFindBy,
|
|
3347
|
+
emailFindBy,
|
|
3348
|
+
smsFindBy,
|
|
3349
|
+
callFindBy,
|
|
3350
|
+
whatsAppFindBy,
|
|
3351
|
+
telegramFindBy,
|
|
3352
|
+
webhookFindBy,
|
|
3353
|
+
notificationRuleFindBy,
|
|
3354
|
+
]) {
|
|
3355
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual(expected);
|
|
3359
|
+
|
|
3360
|
+
for (const spy of [
|
|
3361
|
+
pushFindBy,
|
|
3362
|
+
emailFindBy,
|
|
3363
|
+
smsFindBy,
|
|
3364
|
+
callFindBy,
|
|
3365
|
+
whatsAppFindBy,
|
|
3366
|
+
telegramFindBy,
|
|
3367
|
+
webhookFindBy,
|
|
3368
|
+
notificationRuleFindBy,
|
|
3369
|
+
]) {
|
|
3370
|
+
expect(includedIds(firstCall(spy).query["userId"])).toEqual(expected);
|
|
3371
|
+
}
|
|
3372
|
+
});
|
|
3373
|
+
|
|
3374
|
+
test("every read asks for a full page at a time, never a bare hundred", async () => {
|
|
3375
|
+
attachDirectly(USER_A_ID);
|
|
3376
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
3377
|
+
escalationTeamRow(TEAM_ID),
|
|
3378
|
+
] as never);
|
|
3379
|
+
teamMemberRows = [teamMemberRow(USER_A_ID, TEAM_ID)];
|
|
3380
|
+
escalationScheduleFindBy.mockResolvedValue([
|
|
3381
|
+
escalationScheduleRow(SCHEDULE_ID),
|
|
3382
|
+
] as never);
|
|
3383
|
+
scheduleLayerUserFindBy.mockResolvedValue([
|
|
3384
|
+
layerUserRow(USER_A_ID, SCHEDULE_ID),
|
|
3385
|
+
] as never);
|
|
3386
|
+
setSeverities({
|
|
3387
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
3388
|
+
alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
|
|
3389
|
+
});
|
|
3390
|
+
|
|
3391
|
+
await policySummary();
|
|
3392
|
+
|
|
3393
|
+
const calls: Array<FindByCall> = everyFindByCall();
|
|
3394
|
+
|
|
3395
|
+
// A guard that asserts over an empty list guards nothing.
|
|
3396
|
+
expect(calls.length).toBeGreaterThanOrEqual(15);
|
|
3397
|
+
|
|
3398
|
+
for (const call of calls) {
|
|
3399
|
+
/*
|
|
3400
|
+
* LIMIT_PER_PROJECT is now a PAGE SIZE rather than a cap: every one of
|
|
3401
|
+
* these reads keeps going until a short page comes back (see the paging
|
|
3402
|
+
* tests below). The assertion stays because the page size still has to be
|
|
3403
|
+
* the shared constant rather than a literal, and because a `limit: 100`
|
|
3404
|
+
* reappearing anywhere would be the original truncation defect returning.
|
|
3405
|
+
*/
|
|
3406
|
+
expect(call.limit).toBe(LIMIT_PER_PROJECT);
|
|
3407
|
+
expect(call.limit).not.toBe(100);
|
|
3408
|
+
expect(call.skip).toBe(0);
|
|
3409
|
+
expect(call.props?.isRoot).toBe(true);
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
/*
|
|
3413
|
+
* And the constant is the shared one rather than a literal that happens to
|
|
3414
|
+
* agree with it today.
|
|
3415
|
+
*/
|
|
3416
|
+
expect(LIMIT_PER_PROJECT).toBe(10000);
|
|
3417
|
+
});
|
|
3418
|
+
|
|
3419
|
+
test("severities are read in display order so the coverage grid matches the severity screen", async () => {
|
|
3420
|
+
attachDirectly(USER_A_ID);
|
|
3421
|
+
setSeverities({
|
|
3422
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
3423
|
+
});
|
|
3424
|
+
|
|
3425
|
+
await policySummary();
|
|
3426
|
+
|
|
3427
|
+
/*
|
|
3428
|
+
* Both severity lists are read ascending by `order`, which is the same
|
|
3429
|
+
* order the severity settings screen shows. The coverage grid is a table an
|
|
3430
|
+
* admin fixes cell by cell, so its columns have to line up with the screen
|
|
3431
|
+
* they go to afterwards - a grid sorted by uuid would be unusable without
|
|
3432
|
+
* being visibly wrong.
|
|
3433
|
+
*
|
|
3434
|
+
* `_id` comes after it as a tiebreak rather than instead of it: two
|
|
3435
|
+
* severities with the same `order` would otherwise be free to swap places
|
|
3436
|
+
* between pages, and a read that pages needs a TOTAL order or it can return
|
|
3437
|
+
* one row twice and skip another.
|
|
3438
|
+
*/
|
|
3439
|
+
for (const spy of [incidentSeverityFindBy, alertSeverityFindBy]) {
|
|
3440
|
+
const call: FindByCall = firstCall(spy);
|
|
3441
|
+
|
|
3442
|
+
expect(call.query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
|
|
3443
|
+
expect(call.sort).toEqual({
|
|
3444
|
+
order: SortOrder.Ascending,
|
|
3445
|
+
_id: SortOrder.Ascending,
|
|
3446
|
+
});
|
|
3447
|
+
}
|
|
3448
|
+
});
|
|
3449
|
+
|
|
3450
|
+
test("EVERY read is sorted with _id last, because OFFSET paging over an unstable order loses rows", async () => {
|
|
3451
|
+
attachDirectly(USER_A_ID);
|
|
3452
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
3453
|
+
escalationTeamRow(TEAM_ID),
|
|
3454
|
+
] as never);
|
|
3455
|
+
teamMemberRows = [teamMemberRow(USER_A_ID, TEAM_ID)];
|
|
3456
|
+
escalationScheduleFindBy.mockResolvedValue([
|
|
3457
|
+
escalationScheduleRow(SCHEDULE_ID),
|
|
3458
|
+
] as never);
|
|
3459
|
+
scheduleLayerUserFindBy.mockResolvedValue([
|
|
3460
|
+
layerUserRow(USER_A_ID, SCHEDULE_ID),
|
|
3461
|
+
] as never);
|
|
3462
|
+
setSeverities({
|
|
3463
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
3464
|
+
});
|
|
3465
|
+
|
|
3466
|
+
await policySummary();
|
|
3467
|
+
|
|
3468
|
+
const calls: Array<FindByCall> = everyFindByCall();
|
|
3469
|
+
|
|
3470
|
+
expect(calls.length).toBeGreaterThanOrEqual(15);
|
|
3471
|
+
|
|
3472
|
+
for (const call of calls) {
|
|
3473
|
+
/*
|
|
3474
|
+
* The database layer's own default is `createdAt DESC`, which is NOT
|
|
3475
|
+
* unique - a migration that wrote a project's default notification rules
|
|
3476
|
+
* in one transaction gives thousands of rows the same createdAt, and
|
|
3477
|
+
* paging through them by OFFSET would then be free to return one row on
|
|
3478
|
+
* two pages and another on none. Every read here ends its sort with the
|
|
3479
|
+
* primary key for exactly that reason.
|
|
3480
|
+
*/
|
|
3481
|
+
const sortKeys: Array<string> = Object.keys(call.sort || {});
|
|
3482
|
+
|
|
3483
|
+
expect(sortKeys[sortKeys.length - 1]).toBe("_id");
|
|
3484
|
+
expect(call.sort?.["_id"]).toBe(SortOrder.Ascending);
|
|
3485
|
+
}
|
|
3486
|
+
});
|
|
3487
|
+
|
|
3488
|
+
test("every method read is scoped to the project as well as the user", async () => {
|
|
3489
|
+
attachDirectly(USER_A_ID);
|
|
3490
|
+
|
|
3491
|
+
await policySummary();
|
|
3492
|
+
|
|
3493
|
+
for (const spy of [
|
|
3494
|
+
pushFindBy,
|
|
3495
|
+
emailFindBy,
|
|
3496
|
+
smsFindBy,
|
|
3497
|
+
callFindBy,
|
|
3498
|
+
whatsAppFindBy,
|
|
3499
|
+
telegramFindBy,
|
|
3500
|
+
webhookFindBy,
|
|
3501
|
+
notificationRuleFindBy,
|
|
3502
|
+
]) {
|
|
3503
|
+
expect(firstCall(spy).query["projectId"]?.toString()).toBe(
|
|
3504
|
+
PROJECT_ID.toString(),
|
|
3505
|
+
);
|
|
3506
|
+
}
|
|
3507
|
+
});
|
|
3508
|
+
|
|
3509
|
+
/*
|
|
3510
|
+
* -------------------------------------------------------------------------
|
|
3511
|
+
* Paging, which is the whole of the truncation fix.
|
|
3512
|
+
*
|
|
3513
|
+
* A single capped read is not a performance choice, it is a correctness one,
|
|
3514
|
+
* and it fails in the worst available direction: the rows past the cap are
|
|
3515
|
+
* not reported as missing, they are reported as ABSENT. A responder dropped
|
|
3516
|
+
* that way appears in no count, no list and no "needs attention" section,
|
|
3517
|
+
* which is byte-for-byte identical to a responder who is fine.
|
|
3518
|
+
*
|
|
3519
|
+
* These tests are built around a genuinely full page - LIMIT_PER_PROJECT rows
|
|
3520
|
+
* back from one call - because that is the only signal a pager has that there
|
|
3521
|
+
* might be more, and everything downstream hangs off it.
|
|
3522
|
+
* -------------------------------------------------------------------------
|
|
3523
|
+
*/
|
|
3524
|
+
function fullPageOfDirectResponders(
|
|
3525
|
+
userId: ObjectID,
|
|
3526
|
+
): Array<OnCallDutyPolicyEscalationRuleUser> {
|
|
3527
|
+
const rows: Array<OnCallDutyPolicyEscalationRuleUser> = [];
|
|
3528
|
+
|
|
3529
|
+
for (let index: number = 0; index < LIMIT_PER_PROJECT; index++) {
|
|
3530
|
+
rows.push(escalationUserRow(userId));
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
return rows;
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
test("a responder who lands on the SECOND page is still a responder", async () => {
|
|
3537
|
+
escalationUserFindBy
|
|
3538
|
+
.mockResolvedValueOnce(fullPageOfDirectResponders(USER_A_ID) as never)
|
|
3539
|
+
.mockResolvedValueOnce([escalationUserRow(USER_B_ID)] as never);
|
|
3540
|
+
|
|
3541
|
+
const summary: ReadinessSummary = await policySummary();
|
|
3542
|
+
|
|
3543
|
+
expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
|
|
3544
|
+
expect(callAt(escalationUserFindBy, 0).skip).toBe(0);
|
|
3545
|
+
expect(callAt(escalationUserFindBy, 1).skip).toBe(LIMIT_PER_PROJECT);
|
|
3546
|
+
|
|
3547
|
+
/*
|
|
3548
|
+
* Grace is the responder the old single capped read lost. She has no
|
|
3549
|
+
* notification method at all, so she is exactly the person the table exists
|
|
3550
|
+
* to surface - and the version of this service that stopped at one page
|
|
3551
|
+
* would have counted a project containing her as entirely healthy.
|
|
3552
|
+
*/
|
|
3553
|
+
expect(
|
|
3554
|
+
summary.users.map((user: UserReadiness): string => {
|
|
3555
|
+
return user.userId.toString();
|
|
3556
|
+
}),
|
|
3557
|
+
).toEqual([USER_A_ID.toString(), USER_B_ID.toString()]);
|
|
3558
|
+
expect(summary.notReachableCount).toBe(2);
|
|
3559
|
+
expect(summary.isTruncated).toBe(false);
|
|
3560
|
+
}, 60000);
|
|
3561
|
+
|
|
3562
|
+
test("a notification rule that only exists on the SECOND page still counts as coverage", async () => {
|
|
3563
|
+
attachDirectly(USER_A_ID);
|
|
3564
|
+
pushFindBy.mockResolvedValue([
|
|
3565
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
3566
|
+
] as never);
|
|
3567
|
+
setSeverities({
|
|
3568
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
3569
|
+
});
|
|
3570
|
+
|
|
3571
|
+
/*
|
|
3572
|
+
* This is the read that overflows first in real life: rule rows grow as
|
|
3573
|
+
* users x rule types x severities x methods, so a few thousand responders
|
|
3574
|
+
* is already hundreds of thousands of rows. Under the old single read the
|
|
3575
|
+
* users whose rules sorted late were scored against ZERO rules and reported
|
|
3576
|
+
* as gaps - a table full of false amber, which teaches admins to ignore
|
|
3577
|
+
* amber.
|
|
3578
|
+
*/
|
|
3579
|
+
const filler: Array<UserNotificationRule> = [];
|
|
3580
|
+
|
|
3581
|
+
for (let index: number = 0; index < LIMIT_PER_PROJECT; index++) {
|
|
3582
|
+
filler.push(
|
|
3583
|
+
notificationRule({
|
|
3584
|
+
userId: USER_A_ID,
|
|
3585
|
+
ruleType: NotificationRuleType.WHEN_USER_GOES_ON_CALL,
|
|
3586
|
+
}),
|
|
3587
|
+
);
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
notificationRuleFindBy
|
|
3591
|
+
.mockResolvedValueOnce(filler as never)
|
|
3592
|
+
.mockResolvedValueOnce([
|
|
3593
|
+
notificationRule({
|
|
3594
|
+
userId: USER_A_ID,
|
|
3595
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
3596
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
3597
|
+
}),
|
|
3598
|
+
notificationRule({
|
|
3599
|
+
userId: USER_A_ID,
|
|
3600
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
3601
|
+
incidentSeverityId: INCIDENT_SEVERITY_1_ID,
|
|
3602
|
+
}),
|
|
3603
|
+
] as never);
|
|
3604
|
+
|
|
3605
|
+
const readiness: UserReadiness = await onlyUser();
|
|
3606
|
+
|
|
3607
|
+
expect(notificationRuleFindBy).toHaveBeenCalledTimes(2);
|
|
3608
|
+
expect(readiness.status).toBe(ReadinessStatus.Ready);
|
|
3609
|
+
expect(readiness.reasons).toEqual([]);
|
|
3610
|
+
}, 60000);
|
|
3611
|
+
|
|
3612
|
+
test("a read that never runs out is REPORTED as truncated, not quietly cut short", async () => {
|
|
3613
|
+
const loggedErrors: Array<string> = [];
|
|
3614
|
+
|
|
3615
|
+
jest.spyOn(logger, "error").mockImplementation(((
|
|
3616
|
+
message: unknown,
|
|
3617
|
+
): void => {
|
|
3618
|
+
loggedErrors.push(String(message));
|
|
3619
|
+
}) as never);
|
|
3620
|
+
|
|
3621
|
+
/*
|
|
3622
|
+
* A read that returns a full page forever - a paging bug, or a table growing
|
|
3623
|
+
* faster than it can be read.
|
|
3624
|
+
*/
|
|
3625
|
+
escalationUserFindBy.mockResolvedValue(
|
|
3626
|
+
fullPageOfDirectResponders(USER_A_ID) as never,
|
|
3627
|
+
);
|
|
3628
|
+
|
|
3629
|
+
const summary: ReadinessSummary = await policySummary();
|
|
3630
|
+
|
|
3631
|
+
/*
|
|
3632
|
+
* Two things have to be true at once, and neither is sufficient alone. It
|
|
3633
|
+
* has to STOP - an unbounded loop holding a connection is its own outage -
|
|
3634
|
+
* and it has to SAY SO, both to the caller (isTruncated, which the UI can
|
|
3635
|
+
* render) and to the operator (a logged error, which is where somebody
|
|
3636
|
+
* works out why). A ceiling that only logs is invisible to the admin
|
|
3637
|
+
* reading the table; a ceiling that only flags is invisible to whoever has
|
|
3638
|
+
* to fix it.
|
|
3639
|
+
*/
|
|
3640
|
+
expect(escalationUserFindBy.mock.calls.length).toBeGreaterThan(1);
|
|
3641
|
+
expect(summary.isTruncated).toBe(true);
|
|
3642
|
+
expect(
|
|
3643
|
+
loggedErrors.some((message: string): boolean => {
|
|
3644
|
+
return message.includes("INCOMPLETE");
|
|
3645
|
+
}),
|
|
3646
|
+
).toBe(true);
|
|
3647
|
+
|
|
3648
|
+
// And it still returns everything it did manage to read.
|
|
3649
|
+
expect(summary.users).toHaveLength(1);
|
|
3650
|
+
}, 120000);
|
|
3651
|
+
|
|
3652
|
+
test("an ordinary summary is not flagged as truncated - the flag has to MEAN something", async () => {
|
|
3653
|
+
attachDirectly(USER_A_ID);
|
|
3654
|
+
|
|
3655
|
+
expect((await policySummary()).isTruncated).toBe(false);
|
|
3656
|
+
});
|
|
3657
|
+
});
|
|
3658
|
+
|
|
3659
|
+
/*
|
|
3660
|
+
* ---------------------------------------------------------------------------
|
|
3661
|
+
* (J) getReadinessForUser, including its cross-project guard.
|
|
3662
|
+
* ---------------------------------------------------------------------------
|
|
3663
|
+
*/
|
|
3664
|
+
describe("getReadinessForUser", () => {
|
|
3665
|
+
test("answers for a user who is on no policy at all - which is when the mistake is cheap to fix", async () => {
|
|
3666
|
+
pushFindBy.mockResolvedValue([
|
|
3667
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
3668
|
+
] as never);
|
|
3669
|
+
|
|
3670
|
+
const readiness: UserReadiness =
|
|
3671
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
3672
|
+
|
|
3673
|
+
expect(readiness.reachedVia).toEqual([]);
|
|
3674
|
+
expect(readiness.status).toBe(ReadinessStatus.Ready);
|
|
3675
|
+
});
|
|
3676
|
+
|
|
3677
|
+
test("reachedVia says WHY they are on call, resolved from their own memberships", async () => {
|
|
3678
|
+
/*
|
|
3679
|
+
* The user is in TEAM_ID (their membership row says so) and TEAM_ID is
|
|
3680
|
+
* attached to an escalation rule, so they are reached through the team.
|
|
3681
|
+
* Note what does NOT happen to establish that: the project's teams are
|
|
3682
|
+
* never expanded into their full rosters. The question asked is "is any
|
|
3683
|
+
* team THIS user is in attached to a rule", which is answered by two reads
|
|
3684
|
+
* keyed on this user, not by resolving the whole project.
|
|
3685
|
+
*/
|
|
3686
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
3687
|
+
escalationTeamRow(TEAM_ID),
|
|
3688
|
+
] as never);
|
|
3689
|
+
|
|
3690
|
+
const readiness: UserReadiness =
|
|
3691
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
3692
|
+
|
|
3693
|
+
expect(readiness.reachedVia).toEqual([ResponderSource.Team]);
|
|
3694
|
+
expect(
|
|
3695
|
+
includedIds(firstCall(escalationTeamFindBy).query["teamId"]),
|
|
3696
|
+
).toEqual([TEAM_ID.toString()]);
|
|
3697
|
+
});
|
|
3698
|
+
|
|
3699
|
+
test("a team the user is NOT in does not make them a responder", async () => {
|
|
3700
|
+
/*
|
|
3701
|
+
* The inverse of the test above, and the one that would catch a targeted
|
|
3702
|
+
* resolution that forgot to intersect: OTHER_TEAM_ID is attached to an
|
|
3703
|
+
* escalation rule, but this user is only in TEAM_ID.
|
|
3704
|
+
*/
|
|
3705
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
3706
|
+
escalationTeamRow(OTHER_TEAM_ID),
|
|
3707
|
+
] as never);
|
|
3708
|
+
|
|
3709
|
+
const readiness: UserReadiness =
|
|
3710
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
3711
|
+
|
|
3712
|
+
expect(readiness.reachedVia).toEqual([]);
|
|
3713
|
+
});
|
|
3714
|
+
|
|
3715
|
+
test("a schedule the user is on counts only while that schedule is attached to a rule", async () => {
|
|
3716
|
+
scheduleLayerUserFindBy.mockResolvedValue([
|
|
3717
|
+
layerUserRow(USER_A_ID, SCHEDULE_ID),
|
|
3718
|
+
] as never);
|
|
3719
|
+
|
|
3720
|
+
const unattached: UserReadiness =
|
|
3721
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
3722
|
+
|
|
3723
|
+
expect(unattached.reachedVia).toEqual([]);
|
|
3724
|
+
|
|
3725
|
+
OnCallReadinessService.clearCache();
|
|
3726
|
+
escalationScheduleFindBy.mockResolvedValue([
|
|
3727
|
+
escalationScheduleRow(SCHEDULE_ID),
|
|
3728
|
+
] as never);
|
|
3729
|
+
|
|
3730
|
+
const attached: UserReadiness =
|
|
3731
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
3732
|
+
|
|
3733
|
+
expect(attached.reachedVia).toEqual([ResponderSource.Schedule]);
|
|
3734
|
+
});
|
|
3735
|
+
|
|
3736
|
+
test("refuses a user who is not a member of the project - User is a GLOBAL model", async () => {
|
|
3737
|
+
membershipRows = [];
|
|
3738
|
+
|
|
3739
|
+
await expect(
|
|
3740
|
+
OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID),
|
|
3741
|
+
).rejects.toThrow("User is not a member of this project");
|
|
3742
|
+
|
|
3743
|
+
/*
|
|
3744
|
+
* And it refuses BEFORE reading anything else, so a caller probing arbitrary
|
|
3745
|
+
* user ids learns nothing - not even a name.
|
|
3746
|
+
*/
|
|
3747
|
+
expect(userFindBy).not.toHaveBeenCalled();
|
|
3748
|
+
expect(escalationUserFindBy).not.toHaveBeenCalled();
|
|
3749
|
+
expect(projectFindOneById).not.toHaveBeenCalled();
|
|
3750
|
+
});
|
|
3751
|
+
|
|
3752
|
+
test("the membership check is scoped to the project AND to exactly the users asked about", async () => {
|
|
3753
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
3754
|
+
|
|
3755
|
+
const call: FindByCall = firstCall(teamMemberFindBy);
|
|
3756
|
+
|
|
3757
|
+
expect(call.query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
|
|
3758
|
+
expect(includedIds(call.query["userId"])).toEqual([USER_A_ID.toString()]);
|
|
3759
|
+
|
|
3760
|
+
/*
|
|
3761
|
+
* It reads teamId as well as userId, because that one read is doing two
|
|
3762
|
+
* jobs: it is the cross-project guard AND the input to the Team source.
|
|
3763
|
+
* Dropping teamId from the select would silently cost a second query per
|
|
3764
|
+
* call, which is the N+1 coming back in a smaller coat.
|
|
3765
|
+
*/
|
|
3766
|
+
expect(call.select?.["teamId"]).toBe(true);
|
|
3767
|
+
expect(call.select?.["userId"]).toBe(true);
|
|
3768
|
+
});
|
|
3769
|
+
|
|
3770
|
+
test("a member with no User row is an error rather than a blank card", async () => {
|
|
3771
|
+
userDirectory = [];
|
|
3772
|
+
|
|
3773
|
+
await expect(
|
|
3774
|
+
OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID),
|
|
3775
|
+
).rejects.toThrow("User not found");
|
|
3776
|
+
});
|
|
3777
|
+
|
|
3778
|
+
test("answers only about the user asked for, even when the project has other responders", async () => {
|
|
3779
|
+
attachDirectly(USER_A_ID, USER_B_ID, USER_C_ID);
|
|
3780
|
+
|
|
3781
|
+
const readiness: UserReadiness =
|
|
3782
|
+
await OnCallReadinessService.getReadinessForUser(USER_B_ID, PROJECT_ID);
|
|
3783
|
+
|
|
3784
|
+
expect(readiness.userId.toString()).toBe(USER_B_ID.toString());
|
|
3785
|
+
expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual([
|
|
3786
|
+
USER_B_ID.toString(),
|
|
3787
|
+
]);
|
|
3788
|
+
});
|
|
3789
|
+
|
|
3790
|
+
test("does NOT resolve the whole project's responder set just to fill in reachedVia", async () => {
|
|
3791
|
+
escalationTeamFindBy.mockResolvedValue([
|
|
3792
|
+
escalationTeamRow(TEAM_ID),
|
|
3793
|
+
] as never);
|
|
3794
|
+
teamMemberRows = [
|
|
3795
|
+
teamMemberRow(USER_A_ID, TEAM_ID),
|
|
3796
|
+
teamMemberRow(USER_B_ID, TEAM_ID),
|
|
3797
|
+
teamMemberRow(USER_C_ID, TEAM_ID),
|
|
3798
|
+
];
|
|
3799
|
+
|
|
3800
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
3801
|
+
|
|
3802
|
+
/*
|
|
3803
|
+
* This is the amplified N+1 in one assertion. The old shape asked "who does
|
|
3804
|
+
* this project page?" - reading every escalation rule, expanding every
|
|
3805
|
+
* attached team into its FULL roster and every schedule into its full layer
|
|
3806
|
+
* list - and then looked up one user in the answer. On a project of any
|
|
3807
|
+
* size that is work proportional to the project, repeated once per user, to
|
|
3808
|
+
* produce four booleans.
|
|
3809
|
+
*
|
|
3810
|
+
* Every read below is keyed on the single user instead, and the roster
|
|
3811
|
+
* expansion (a TeamMember read by teamId) never happens at all.
|
|
3812
|
+
*/
|
|
3813
|
+
for (const spy of [escalationUserFindBy, scheduleLayerUserFindBy]) {
|
|
3814
|
+
expect(includedIds(firstCall(spy).query["userId"])).toEqual([
|
|
3815
|
+
USER_A_ID.toString(),
|
|
3816
|
+
]);
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
expect(
|
|
3820
|
+
includedIds(firstCall(overrideFindBy).query["routeAlertsToUserId"]),
|
|
3821
|
+
).toEqual([USER_A_ID.toString()]);
|
|
3822
|
+
|
|
3823
|
+
const rosterExpansions: Array<FindByCall> = teamMemberFindBy.mock.calls
|
|
3824
|
+
.map((call: Array<unknown>): FindByCall => {
|
|
3825
|
+
return call[0] as FindByCall;
|
|
3826
|
+
})
|
|
3827
|
+
.filter((call: FindByCall): boolean => {
|
|
3828
|
+
return Boolean(call.query["teamId"]);
|
|
3829
|
+
});
|
|
3830
|
+
|
|
3831
|
+
expect(rosterExpansions).toEqual([]);
|
|
3832
|
+
});
|
|
3833
|
+
});
|
|
3834
|
+
|
|
3835
|
+
/*
|
|
3836
|
+
* ---------------------------------------------------------------------------
|
|
3837
|
+
* (J2) getReadinessForUsers - the batch entry point.
|
|
3838
|
+
*
|
|
3839
|
+
* This exists so that a list-shaped caller (a team roster, a responder table, a
|
|
3840
|
+
* row of chips) has something to call that is not `Promise.all(ids.map(...))`.
|
|
3841
|
+
* That shape was not a small inefficiency: each call resolved the ENTIRE
|
|
3842
|
+
* project's responder set to compute one user's reachedVia, so a forty-member
|
|
3843
|
+
* team issued several hundred queries, and one rejection - a member removed
|
|
3844
|
+
* between the two reads is enough - threw away the whole page.
|
|
3845
|
+
*
|
|
3846
|
+
* The property being pinned is CONSTANT COST in the size of the input set. Not
|
|
3847
|
+
* "fewer queries", not "faster": the same number, for one user and for five
|
|
3848
|
+
* hundred, because every read is one Includes over the whole set.
|
|
3849
|
+
* ---------------------------------------------------------------------------
|
|
3850
|
+
*/
|
|
3851
|
+
describe("getReadinessForUsers", () => {
|
|
3852
|
+
function seedMembers(count: number): Array<ObjectID> {
|
|
3853
|
+
const ids: Array<ObjectID> = [];
|
|
3854
|
+
|
|
3855
|
+
for (let index: number = 0; index < count; index++) {
|
|
3856
|
+
ids.push(
|
|
3857
|
+
new ObjectID(
|
|
3858
|
+
`beef0000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
|
3859
|
+
),
|
|
3860
|
+
);
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
userDirectory = ids.map((id: ObjectID, index: number): User => {
|
|
3864
|
+
return makeUser(
|
|
3865
|
+
id,
|
|
3866
|
+
`Responder ${index}`,
|
|
3867
|
+
`responder${index}@corp.example.com`,
|
|
3868
|
+
);
|
|
3869
|
+
});
|
|
3870
|
+
membershipRows = ids.map((id: ObjectID): TeamMember => {
|
|
3871
|
+
return teamMemberRow(id, TEAM_ID);
|
|
3872
|
+
});
|
|
3873
|
+
|
|
3874
|
+
return ids;
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
test("five hundred users cost exactly the same number of queries as one", async () => {
|
|
3878
|
+
const one: Array<ObjectID> = seedMembers(1);
|
|
3879
|
+
|
|
3880
|
+
await OnCallReadinessService.getReadinessForUsers(one, PROJECT_ID);
|
|
3881
|
+
|
|
3882
|
+
const costOfOne: number = totalQueryCount();
|
|
3883
|
+
|
|
3884
|
+
expect(costOfOne).toBeGreaterThan(0);
|
|
3885
|
+
|
|
3886
|
+
jest.clearAllMocks();
|
|
3887
|
+
OnCallReadinessService.clearCache();
|
|
3888
|
+
|
|
3889
|
+
const many: Array<ObjectID> = seedMembers(500);
|
|
3890
|
+
|
|
3891
|
+
const readiness: Array<UserReadiness> =
|
|
3892
|
+
await OnCallReadinessService.getReadinessForUsers(many, PROJECT_ID);
|
|
3893
|
+
|
|
3894
|
+
expect(readiness).toHaveLength(500);
|
|
3895
|
+
|
|
3896
|
+
/*
|
|
3897
|
+
* Five hundred times the users and not one extra round trip. The old
|
|
3898
|
+
* fan-out would have issued something like nine thousand.
|
|
3899
|
+
*/
|
|
3900
|
+
expect(totalQueryCount()).toBe(costOfOne);
|
|
3901
|
+
}, 60000);
|
|
3902
|
+
|
|
3903
|
+
test("every read is keyed on the users asked about, and no team is ever expanded", async () => {
|
|
3904
|
+
const ids: Array<ObjectID> = seedMembers(3);
|
|
3905
|
+
const expected: Array<string> = ids.map((id: ObjectID): string => {
|
|
3906
|
+
return id.toString();
|
|
3907
|
+
});
|
|
3908
|
+
|
|
3909
|
+
await OnCallReadinessService.getReadinessForUsers(ids, PROJECT_ID);
|
|
3910
|
+
|
|
3911
|
+
expect(includedIds(firstCall(teamMemberFindBy).query["userId"])).toEqual(
|
|
3912
|
+
expected,
|
|
3913
|
+
);
|
|
3914
|
+
expect(
|
|
3915
|
+
includedIds(firstCall(escalationUserFindBy).query["userId"]),
|
|
3916
|
+
).toEqual(expected);
|
|
3917
|
+
expect(
|
|
3918
|
+
includedIds(firstCall(scheduleLayerUserFindBy).query["userId"]),
|
|
3919
|
+
).toEqual(expected);
|
|
3920
|
+
expect(
|
|
3921
|
+
includedIds(firstCall(overrideFindBy).query["routeAlertsToUserId"]),
|
|
3922
|
+
).toEqual(expected);
|
|
3923
|
+
expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual(expected);
|
|
3924
|
+
|
|
3925
|
+
for (const spy of [
|
|
3926
|
+
pushFindBy,
|
|
3927
|
+
emailFindBy,
|
|
3928
|
+
smsFindBy,
|
|
3929
|
+
callFindBy,
|
|
3930
|
+
whatsAppFindBy,
|
|
3931
|
+
telegramFindBy,
|
|
3932
|
+
webhookFindBy,
|
|
3933
|
+
notificationRuleFindBy,
|
|
3934
|
+
]) {
|
|
3935
|
+
expect(includedIds(firstCall(spy).query["userId"])).toEqual(expected);
|
|
3936
|
+
}
|
|
3937
|
+
});
|
|
3938
|
+
|
|
3939
|
+
test("a user who is not a member is OMITTED, not thrown for - one departure must not blank the page", async () => {
|
|
3940
|
+
const ids: Array<ObjectID> = seedMembers(3);
|
|
3941
|
+
|
|
3942
|
+
/*
|
|
3943
|
+
* Exactly what happens when somebody is removed from a team between the
|
|
3944
|
+
* page being requested and this read running. The single-user entry point
|
|
3945
|
+
* throws for that user, which is right for a card about one person and
|
|
3946
|
+
* catastrophic for a roster: under Promise.all it discards thirty-nine
|
|
3947
|
+
* other people's readiness because one person left.
|
|
3948
|
+
*/
|
|
3949
|
+
membershipRows = membershipRows.filter((row: TeamMember): boolean => {
|
|
3950
|
+
return row.userId?.toString() !== ids[1]!.toString();
|
|
3951
|
+
});
|
|
3952
|
+
|
|
3953
|
+
const readiness: Array<UserReadiness> =
|
|
3954
|
+
await OnCallReadinessService.getReadinessForUsers(ids, PROJECT_ID);
|
|
3955
|
+
|
|
3956
|
+
expect(readiness).toHaveLength(2);
|
|
3957
|
+
expect(
|
|
3958
|
+
readiness.map((one: UserReadiness): string => {
|
|
3959
|
+
return one.userId.toString();
|
|
3960
|
+
}),
|
|
3961
|
+
).not.toContain(ids[1]!.toString());
|
|
3962
|
+
});
|
|
3963
|
+
|
|
3964
|
+
test("no member of the requested set at all is an empty list rather than an error", async () => {
|
|
3965
|
+
const ids: Array<ObjectID> = seedMembers(2);
|
|
3966
|
+
membershipRows = [];
|
|
3967
|
+
|
|
3968
|
+
await expect(
|
|
3969
|
+
OnCallReadinessService.getReadinessForUsers(ids, PROJECT_ID),
|
|
3970
|
+
).resolves.toEqual([]);
|
|
3971
|
+
|
|
3972
|
+
// And it stops after the membership read rather than reading anything else.
|
|
3973
|
+
expect(userFindBy).not.toHaveBeenCalled();
|
|
3974
|
+
expect(escalationUserFindBy).not.toHaveBeenCalled();
|
|
3975
|
+
});
|
|
3976
|
+
|
|
3977
|
+
test("an empty request asks the database nothing at all", async () => {
|
|
3978
|
+
await expect(
|
|
3979
|
+
OnCallReadinessService.getReadinessForUsers([], PROJECT_ID),
|
|
3980
|
+
).resolves.toEqual([]);
|
|
3981
|
+
|
|
3982
|
+
expect(totalQueryCount()).toBe(0);
|
|
3983
|
+
});
|
|
3984
|
+
|
|
3985
|
+
test("a duplicated user id is answered once", async () => {
|
|
3986
|
+
const readiness: Array<UserReadiness> =
|
|
3987
|
+
await OnCallReadinessService.getReadinessForUsers(
|
|
3988
|
+
[USER_A_ID, USER_A_ID, USER_A_ID],
|
|
3989
|
+
PROJECT_ID,
|
|
3990
|
+
);
|
|
3991
|
+
|
|
3992
|
+
expect(readiness).toHaveLength(1);
|
|
3993
|
+
expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual([
|
|
3994
|
+
USER_A_ID.toString(),
|
|
3995
|
+
]);
|
|
3996
|
+
});
|
|
3997
|
+
|
|
3998
|
+
test("the batch answer for a user is the answer the single call gives", async () => {
|
|
3999
|
+
attachDirectly(USER_A_ID);
|
|
4000
|
+
pushFindBy.mockResolvedValue([
|
|
4001
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
4002
|
+
] as never);
|
|
4003
|
+
setSeverities({
|
|
4004
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
4005
|
+
});
|
|
4006
|
+
|
|
4007
|
+
const batch: Array<UserReadiness> =
|
|
4008
|
+
await OnCallReadinessService.getReadinessForUsers(
|
|
4009
|
+
[USER_A_ID, USER_B_ID],
|
|
4010
|
+
PROJECT_ID,
|
|
4011
|
+
);
|
|
4012
|
+
|
|
4013
|
+
OnCallReadinessService.clearCache();
|
|
4014
|
+
|
|
4015
|
+
const single: UserReadiness =
|
|
4016
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
4017
|
+
|
|
4018
|
+
const fromBatch: UserReadiness = batch.find(
|
|
4019
|
+
(one: UserReadiness): boolean => {
|
|
4020
|
+
return one.userId.toString() === USER_A_ID.toString();
|
|
4021
|
+
},
|
|
4022
|
+
)!;
|
|
4023
|
+
|
|
4024
|
+
expect(fromBatch.status).toBe(single.status);
|
|
4025
|
+
expect(fromBatch.reachedVia).toEqual(single.reachedVia);
|
|
4026
|
+
expect(fromBatch.reasons).toEqual(single.reasons);
|
|
4027
|
+
expect(fromBatch.coverage).toEqual(single.coverage);
|
|
4028
|
+
});
|
|
4029
|
+
|
|
4030
|
+
test("results are sorted most-broken-first, the same as a summary", async () => {
|
|
4031
|
+
pushFindBy.mockResolvedValue([
|
|
4032
|
+
pushMethod({ userId: USER_B_ID, isVerified: true }),
|
|
4033
|
+
] as never);
|
|
4034
|
+
|
|
4035
|
+
const readiness: Array<UserReadiness> =
|
|
4036
|
+
await OnCallReadinessService.getReadinessForUsers(
|
|
4037
|
+
[USER_B_ID, USER_A_ID],
|
|
4038
|
+
PROJECT_ID,
|
|
4039
|
+
);
|
|
4040
|
+
|
|
4041
|
+
expect(
|
|
4042
|
+
readiness.map((one: UserReadiness): ReadinessStatus => {
|
|
4043
|
+
return one.status;
|
|
4044
|
+
}),
|
|
4045
|
+
).toEqual([ReadinessStatus.NotReachable, ReadinessStatus.Ready]);
|
|
4046
|
+
});
|
|
4047
|
+
|
|
4048
|
+
test("a cached user costs no round trip, and only the uncached ones are read", async () => {
|
|
4049
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
4050
|
+
|
|
4051
|
+
jest.clearAllMocks();
|
|
4052
|
+
|
|
4053
|
+
const readiness: Array<UserReadiness> =
|
|
4054
|
+
await OnCallReadinessService.getReadinessForUsers(
|
|
4055
|
+
[USER_A_ID, USER_B_ID],
|
|
4056
|
+
PROJECT_ID,
|
|
4057
|
+
);
|
|
4058
|
+
|
|
4059
|
+
expect(readiness).toHaveLength(2);
|
|
4060
|
+
|
|
4061
|
+
/*
|
|
4062
|
+
* Ada came out of the cache, so the reads that follow ask about Grace
|
|
4063
|
+
* alone. A batch that re-read every user whenever one of them was cold
|
|
4064
|
+
* would make the cache almost worthless on exactly the list-shaped callers
|
|
4065
|
+
* this entry point exists for.
|
|
4066
|
+
*/
|
|
4067
|
+
expect(includedIds(firstCall(teamMemberFindBy).query["userId"])).toEqual([
|
|
4068
|
+
USER_B_ID.toString(),
|
|
4069
|
+
]);
|
|
4070
|
+
expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual([
|
|
4071
|
+
USER_B_ID.toString(),
|
|
4072
|
+
]);
|
|
4073
|
+
});
|
|
4074
|
+
|
|
4075
|
+
test("a second identical batch re-queries nothing", async () => {
|
|
4076
|
+
await OnCallReadinessService.getReadinessForUsers(
|
|
4077
|
+
[USER_A_ID, USER_B_ID],
|
|
4078
|
+
PROJECT_ID,
|
|
4079
|
+
);
|
|
4080
|
+
|
|
4081
|
+
const countAfterFirst: number = totalQueryCount();
|
|
4082
|
+
|
|
4083
|
+
await OnCallReadinessService.getReadinessForUsers(
|
|
4084
|
+
[USER_A_ID, USER_B_ID],
|
|
4085
|
+
PROJECT_ID,
|
|
4086
|
+
);
|
|
4087
|
+
|
|
4088
|
+
expect(totalQueryCount()).toBe(countAfterFirst);
|
|
4089
|
+
});
|
|
4090
|
+
});
|
|
4091
|
+
|
|
4092
|
+
/*
|
|
4093
|
+
* ---------------------------------------------------------------------------
|
|
4094
|
+
* (J3) The fallback signal on the wire.
|
|
4095
|
+
*
|
|
4096
|
+
* Every readiness surface wants to tell an admin what a PartiallyReady
|
|
4097
|
+
* responder actually costs them, and the honest answer depends on one project
|
|
4098
|
+
* switch: with the fallback on, an uncovered severity still reaches the person
|
|
4099
|
+
* through their verified methods; with it off, that page is dropped. A UI that
|
|
4100
|
+
* cannot tell those apart says "nothing is dropped" at the exact moment
|
|
4101
|
+
* something is, which is worse than saying nothing at all.
|
|
4102
|
+
* ---------------------------------------------------------------------------
|
|
4103
|
+
*/
|
|
4104
|
+
describe("isFallbackEnabled", () => {
|
|
4105
|
+
test("true when the project leaves the fallback on", async () => {
|
|
4106
|
+
attachDirectly(USER_A_ID);
|
|
4107
|
+
|
|
4108
|
+
expect((await policySummary()).isFallbackEnabled).toBe(true);
|
|
4109
|
+
});
|
|
4110
|
+
|
|
4111
|
+
test("false when the project disables it, and the per-user reasons agree", async () => {
|
|
4112
|
+
attachDirectly(USER_A_ID);
|
|
4113
|
+
projectFindOneById.mockResolvedValue(
|
|
4114
|
+
makeProject({ disableOnCallNotificationFallback: true }) as never,
|
|
4115
|
+
);
|
|
4116
|
+
pushFindBy.mockResolvedValue([
|
|
4117
|
+
pushMethod({ userId: USER_A_ID, isVerified: true }),
|
|
4118
|
+
] as never);
|
|
4119
|
+
setSeverities({
|
|
4120
|
+
incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
|
|
4121
|
+
});
|
|
4122
|
+
|
|
4123
|
+
const summary: ReadinessSummary = await policySummary();
|
|
4124
|
+
|
|
4125
|
+
expect(summary.isFallbackEnabled).toBe(false);
|
|
4126
|
+
expect(summary.users[0]!.reasons[0]).toBe(
|
|
4127
|
+
"No rules for Sev1 incidents - pages are dropped because on-call fallback is disabled for this project",
|
|
4128
|
+
);
|
|
4129
|
+
});
|
|
4130
|
+
|
|
4131
|
+
test("reported even when the project has no responders yet", async () => {
|
|
4132
|
+
projectFindOneById.mockResolvedValue(
|
|
4133
|
+
makeProject({ disableOnCallNotificationFallback: true }) as never,
|
|
4134
|
+
);
|
|
4135
|
+
|
|
4136
|
+
const summary: ReadinessSummary = await policySummary();
|
|
4137
|
+
|
|
4138
|
+
/*
|
|
4139
|
+
* A project with nobody attached is precisely the project somebody is about
|
|
4140
|
+
* to attach somebody to, and "pages with no matching rule are dropped here"
|
|
4141
|
+
* is the thing they need to know before they do it - not after.
|
|
4142
|
+
*/
|
|
4143
|
+
expect(summary.users).toEqual([]);
|
|
4144
|
+
expect(summary.isFallbackEnabled).toBe(false);
|
|
4145
|
+
});
|
|
4146
|
+
|
|
4147
|
+
test("the project scope reports it too, not just the policy scope", async () => {
|
|
4148
|
+
attachDirectly(USER_A_ID);
|
|
4149
|
+
projectFindOneById.mockResolvedValue(
|
|
4150
|
+
makeProject({ disableOnCallNotificationFallback: true }) as never,
|
|
4151
|
+
);
|
|
4152
|
+
|
|
4153
|
+
const summary: ReadinessSummary =
|
|
4154
|
+
await OnCallReadinessService.getReadinessForProject(PROJECT_ID);
|
|
4155
|
+
|
|
4156
|
+
expect(summary.isFallbackEnabled).toBe(false);
|
|
4157
|
+
expect(summary.isTruncated).toBe(false);
|
|
4158
|
+
});
|
|
4159
|
+
});
|
|
4160
|
+
|
|
4161
|
+
/*
|
|
4162
|
+
* ---------------------------------------------------------------------------
|
|
4163
|
+
* (K) The policy guard.
|
|
4164
|
+
* ---------------------------------------------------------------------------
|
|
4165
|
+
*/
|
|
4166
|
+
describe("getReadinessForPolicy guards", () => {
|
|
4167
|
+
test("an unknown policy is an error, not an empty and reassuring summary", async () => {
|
|
4168
|
+
policyFindOneById.mockResolvedValue(null as never);
|
|
4169
|
+
|
|
4170
|
+
await expect(policySummary()).rejects.toThrow(
|
|
4171
|
+
"On-call duty policy not found",
|
|
4172
|
+
);
|
|
4173
|
+
expect(escalationUserFindBy).not.toHaveBeenCalled();
|
|
4174
|
+
});
|
|
4175
|
+
|
|
4176
|
+
test("a policy from another project is refused, because every read below would return nothing", async () => {
|
|
4177
|
+
policyFindOneById.mockResolvedValue(makePolicy(OTHER_PROJECT_ID) as never);
|
|
4178
|
+
|
|
4179
|
+
/*
|
|
4180
|
+
* "0 responders, nothing wrong" is the most dangerous possible answer to
|
|
4181
|
+
* "is this policy safe to rely on?".
|
|
4182
|
+
*/
|
|
4183
|
+
await expect(policySummary()).rejects.toThrow(
|
|
4184
|
+
"On-call duty policy not found",
|
|
4185
|
+
);
|
|
4186
|
+
expect(escalationUserFindBy).not.toHaveBeenCalled();
|
|
4187
|
+
});
|
|
4188
|
+
|
|
4189
|
+
test("a policy row with no projectId at all is refused", async () => {
|
|
4190
|
+
const orphan: OnCallDutyPolicy = new OnCallDutyPolicy();
|
|
4191
|
+
orphan.id = POLICY_ID;
|
|
4192
|
+
policyFindOneById.mockResolvedValue(orphan as never);
|
|
4193
|
+
|
|
4194
|
+
await expect(policySummary()).rejects.toThrow(
|
|
4195
|
+
"On-call duty policy not found",
|
|
4196
|
+
);
|
|
4197
|
+
});
|
|
4198
|
+
});
|
|
4199
|
+
|
|
4200
|
+
/*
|
|
4201
|
+
* ---------------------------------------------------------------------------
|
|
4202
|
+
* (L) The cache.
|
|
4203
|
+
*
|
|
4204
|
+
* 60 seconds, per replica, with no cross-process invalidation - so the TTL is
|
|
4205
|
+
* the only guarantee and the keying has to be exactly right. A summary served
|
|
4206
|
+
* under the wrong key is a readiness answer for the wrong scope, which is worse
|
|
4207
|
+
* than a slow one.
|
|
4208
|
+
* ---------------------------------------------------------------------------
|
|
4209
|
+
*/
|
|
4210
|
+
describe("cache", () => {
|
|
4211
|
+
test("a second call inside the TTL re-queries nothing", async () => {
|
|
4212
|
+
attachDirectly(USER_A_ID);
|
|
4213
|
+
|
|
4214
|
+
const first: ReadinessSummary = await policySummary();
|
|
4215
|
+
const countAfterFirst: number = totalQueryCount();
|
|
4216
|
+
const second: ReadinessSummary = await policySummary();
|
|
4217
|
+
|
|
4218
|
+
expect(totalQueryCount()).toBe(countAfterFirst);
|
|
4219
|
+
expect(second).toBe(first);
|
|
4220
|
+
});
|
|
4221
|
+
|
|
4222
|
+
test("clearCache forces a recompute, so an admin who just fixed something sees it fixed", async () => {
|
|
4223
|
+
attachDirectly(USER_A_ID);
|
|
4224
|
+
|
|
4225
|
+
await policySummary();
|
|
4226
|
+
expect(escalationUserFindBy).toHaveBeenCalledTimes(1);
|
|
4227
|
+
|
|
4228
|
+
await policySummary();
|
|
4229
|
+
expect(escalationUserFindBy).toHaveBeenCalledTimes(1);
|
|
4230
|
+
|
|
4231
|
+
OnCallReadinessService.clearCache();
|
|
4232
|
+
|
|
4233
|
+
await policySummary();
|
|
4234
|
+
expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
|
|
4235
|
+
});
|
|
4236
|
+
|
|
4237
|
+
test("clearCache empties the user cache as well as the summary cache", async () => {
|
|
4238
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
4239
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
4240
|
+
|
|
4241
|
+
expect(userFindBy).toHaveBeenCalledTimes(1);
|
|
4242
|
+
|
|
4243
|
+
OnCallReadinessService.clearCache();
|
|
4244
|
+
|
|
4245
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
4246
|
+
|
|
4247
|
+
expect(userFindBy).toHaveBeenCalledTimes(2);
|
|
4248
|
+
});
|
|
4249
|
+
|
|
4250
|
+
test("the project scope and the policy scope are separate entries", async () => {
|
|
4251
|
+
attachDirectly(USER_A_ID);
|
|
4252
|
+
|
|
4253
|
+
await OnCallReadinessService.getReadinessForProject(PROJECT_ID);
|
|
4254
|
+
await policySummary();
|
|
4255
|
+
|
|
4256
|
+
expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
|
|
4257
|
+
expect(
|
|
4258
|
+
firstCall(escalationUserFindBy).query["onCallDutyPolicyId"],
|
|
4259
|
+
).toBeUndefined();
|
|
4260
|
+
expect(
|
|
4261
|
+
callAt(escalationUserFindBy, 1).query["onCallDutyPolicyId"]?.toString(),
|
|
4262
|
+
).toBe(POLICY_ID.toString());
|
|
4263
|
+
});
|
|
4264
|
+
|
|
4265
|
+
test("two policies in one project do not share an entry", async () => {
|
|
4266
|
+
attachDirectly(USER_A_ID);
|
|
4267
|
+
|
|
4268
|
+
await policySummary();
|
|
4269
|
+
await OnCallReadinessService.getReadinessForPolicy(
|
|
4270
|
+
OTHER_POLICY_ID,
|
|
4271
|
+
PROJECT_ID,
|
|
4272
|
+
);
|
|
4273
|
+
|
|
4274
|
+
expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
|
|
4275
|
+
});
|
|
4276
|
+
|
|
4277
|
+
test("the project scope is keyed on the project, so another project is not served this one's answer", async () => {
|
|
4278
|
+
attachDirectly(USER_A_ID);
|
|
4279
|
+
|
|
4280
|
+
await OnCallReadinessService.getReadinessForProject(PROJECT_ID);
|
|
4281
|
+
await OnCallReadinessService.getReadinessForProject(OTHER_PROJECT_ID);
|
|
4282
|
+
|
|
4283
|
+
expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
|
|
4284
|
+
expect(callAt(escalationUserFindBy, 1).query["projectId"]?.toString()).toBe(
|
|
4285
|
+
OTHER_PROJECT_ID.toString(),
|
|
4286
|
+
);
|
|
4287
|
+
});
|
|
4288
|
+
|
|
4289
|
+
test("two users do not share an entry", async () => {
|
|
4290
|
+
await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
|
|
4291
|
+
await OnCallReadinessService.getReadinessForUser(USER_B_ID, PROJECT_ID);
|
|
4292
|
+
|
|
4293
|
+
expect(userFindBy).toHaveBeenCalledTimes(2);
|
|
4294
|
+
});
|
|
4295
|
+
});
|