@oneuptime/common 12.0.7 → 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} +184 -9
- package/Models/DatabaseModels/InventoryItemCustomField.ts +434 -0
- package/Models/DatabaseModels/{TelemetryEntityRelationship.ts → InventoryItemRelationship.ts} +33 -7
- 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/WorkflowLog.ts +38 -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/API/UserAPI.ts +16 -1
- 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/1786551733814-MigrationName.ts +41 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
- 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 +24 -0
- package/Server/Infrastructure/Queue.ts +78 -13
- package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
- 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} +7 -4
- package/Server/Services/{TelemetryEntityService.ts → InventoryItemService.ts} +203 -21
- 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/API/Utils.ts +44 -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/Types/Workflow/TriggerCode.ts +12 -0
- package/Server/Types/Workflow/Workflow.ts +5 -0
- 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 +689 -0
- package/Server/Utils/Telemetry/TelemetryEntity.ts +160 -52
- package/Server/Utils/VM/VMAPI.ts +56 -8
- 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/MonitorCriteriaAttributeFilter.test.tsx +574 -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/API/UserProjectsAPI.test.ts +478 -4
- 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/InventoryItemManualCreate.test.ts +248 -0
- 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/ApiComponentHeaders.test.ts +192 -0
- 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/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -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 +322 -0
- package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +356 -39
- package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
- 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/SerializableObjectDictionaryImportCycle.test.ts +197 -0
- package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
- 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/StepTrace.test.ts +235 -0
- package/Tests/Types/Workflow/TemplateSyntax.test.ts +491 -0
- package/Tests/Types/Workflow/Templates.test.ts +1218 -0
- package/Tests/UI/Components/ActiveFilterChipsOpenRoute.test.tsx +82 -0
- package/Tests/UI/Components/ComponentsModal.test.tsx +564 -7
- package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
- package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
- package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
- package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
- package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
- package/Tests/UI/Components/KeyboardShortcut.test.tsx +95 -0
- package/Tests/UI/Components/LogDetailsPanelCrossSignal.test.tsx +448 -0
- package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
- package/Tests/UI/Components/LogsTableCrossLinks.test.tsx +263 -0
- package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -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/TelemetryTimeRangePicker.test.tsx +49 -7
- package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
- package/Tests/UI/Components/Workflow/GraphLint.test.ts +893 -0
- package/Tests/UI/Components/Workflow/GraphLintSummary.test.ts +755 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +522 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +193 -0
- package/Tests/UI/Components/Workflow/ModelSchema.test.ts +388 -0
- package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +210 -0
- 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/Utils.test.ts +192 -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/Monitor/MonitorStepCriteriaView.test.tsx +432 -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/NetworkDeviceRoleUtil.test.ts +514 -0
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +727 -0
- package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
- package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +698 -0
- package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -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 +80 -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/SerializableObjectDictionary.ts +133 -39
- package/Types/Telemetry/EntityRelationshipType.ts +1 -1
- package/Types/Telemetry/EntitySource.ts +40 -0
- package/Types/Telemetry/EntityType.ts +28 -1
- package/Types/Telemetry/EntityTypeGroups.ts +65 -0
- package/Types/Workflow/Component.ts +29 -0
- package/Types/Workflow/Components/API.ts +35 -0
- package/Types/Workflow/Components/BaseModel.ts +77 -27
- 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/StepTrace.ts +181 -0
- package/Types/Workflow/TemplateSyntax.ts +543 -0
- package/Types/Workflow/Templates.ts +2368 -0
- 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/Date/CustomTimeRangeModal.tsx +278 -0
- package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
- package/UI/Components/Dictionary/Dictionary.tsx +71 -15
- package/UI/Components/FormModal/BasicFormModal.tsx +2 -1
- package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
- package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
- package/UI/Components/Forms/Types/Field.ts +7 -0
- package/UI/Components/Forms/Validation.ts +63 -1
- package/UI/Components/Header/HeaderIconDropdownButton.tsx +53 -3
- package/UI/Components/Input/Input.tsx +32 -3
- 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/LogTimeRangePicker.tsx +11 -216
- 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/Modal/Modal.tsx +37 -4
- 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/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
- package/UI/Components/Workflow/ArgumentsForm.tsx +341 -8
- package/UI/Components/Workflow/Component.tsx +28 -26
- package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +26 -0
- package/UI/Components/Workflow/ComponentSettingsModal.tsx +79 -9
- 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 +628 -0
- package/UI/Components/Workflow/GraphLintSummary.ts +390 -0
- package/UI/Components/Workflow/ModelColumnEditor.tsx +528 -0
- package/UI/Components/Workflow/ModelSchema.ts +278 -0
- package/UI/Components/Workflow/RunForm.tsx +41 -7
- package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
- package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
- package/UI/Components/Workflow/UseRunWatch.ts +212 -0
- package/UI/Components/Workflow/Utils.ts +93 -1
- package/UI/Components/Workflow/VariableModal.tsx +6 -2
- package/UI/Components/Workflow/Workflow.tsx +126 -7
- 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/Types/LayeredDismissal.ts +31 -0
- package/UI/Types/UseAnchoredFieldPopup.ts +99 -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/NetworkDeviceRoleUtil.ts +533 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +917 -155
- package/Utils/Telemetry/CrossSignalScope.ts +502 -0
- package/Utils/Telemetry/EntityKey.ts +71 -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} +212 -29
- 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} +52 -25
- 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/WorkflowLog.js +39 -0
- package/build/dist/Models/DatabaseModels/WorkflowLog.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/API/UserAPI.js +16 -1
- package/build/dist/Server/API/UserAPI.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/1786551733814-MigrationName.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
- 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 +24 -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/MasterAdminAuthorization.js +11 -6
- package/build/dist/Server/Middleware/MasterAdminAuthorization.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} +9 -6
- package/build/dist/Server/Services/InventoryItemRelationshipService.js.map +1 -0
- package/build/dist/Server/Services/{TelemetryEntityService.js → InventoryItemService.js} +158 -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/API/Utils.js +28 -0
- package/build/dist/Server/Types/Workflow/Components/API/Utils.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/Types/Workflow/TriggerCode.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 +507 -0
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
- 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/VM/VMAPI.js +51 -4
- package/build/dist/Server/Utils/VM/VMAPI.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/SerializableObjectDictionary.js +133 -39
- package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityRelationshipType.js +1 -1
- package/build/dist/Types/Telemetry/EntitySource.js +39 -0
- package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
- package/build/dist/Types/Telemetry/EntityType.js +28 -1
- package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
- package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
- package/build/dist/Types/Workflow/Component.js +21 -0
- package/build/dist/Types/Workflow/Component.js.map +1 -1
- package/build/dist/Types/Workflow/Components/API.js +35 -0
- package/build/dist/Types/Workflow/Components/API.js.map +1 -1
- package/build/dist/Types/Workflow/Components/BaseModel.js +68 -26
- 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/StepTrace.js +104 -0
- package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js +338 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
- package/build/dist/Types/Workflow/Templates.js +2111 -0
- package/build/dist/Types/Workflow/Templates.js.map +1 -0
- 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/Date/CustomTimeRangeModal.js +126 -0
- package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
- package/build/dist/UI/Components/Dictionary/Dictionary.js +47 -17
- 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/Forms/Fields/ColorPicker.js +52 -13
- package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
- package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
- package/build/dist/UI/Components/Forms/Validation.js +44 -0
- package/build/dist/UI/Components/Forms/Validation.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 +13 -4
- 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/LogTimeRangePicker.js +5 -108
- package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.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/Modal/Modal.js +31 -5
- package/build/dist/UI/Components/Modal/Modal.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/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +261 -14
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/Component.js +20 -19
- package/build/dist/UI/Components/Workflow/Component.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 +35 -7
- 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 +425 -0
- package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
- 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 +320 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelSchema.js +156 -0
- package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
- package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
- package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
- 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/Utils.js +73 -1
- package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
- 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 +92 -7
- 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/Types/LayeredDismissal.js +21 -0
- package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
- package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
- package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
- 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/NetworkDeviceRoleUtil.js +386 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +661 -126
- 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 +57 -5
- package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
- 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,2680 @@
|
|
|
1
|
+
import { mockRouter } from "./Helpers";
|
|
2
|
+
import OnCallReadinessAPI from "../../../Server/API/OnCallReadinessAPI";
|
|
3
|
+
import TeamComplianceAPI from "../../../Server/API/TeamComplianceAPI";
|
|
4
|
+
import TeamComplianceService from "../../../Server/Services/TeamComplianceService";
|
|
5
|
+
import CommonAPI from "../../../Server/API/CommonAPI";
|
|
6
|
+
import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
|
|
7
|
+
import OnCallReadinessService, {
|
|
8
|
+
IDENTIFIER_MASK,
|
|
9
|
+
ReadinessMethodType,
|
|
10
|
+
ReadinessStatus,
|
|
11
|
+
ReadinessSummary,
|
|
12
|
+
ResponderSource,
|
|
13
|
+
UserReadiness,
|
|
14
|
+
} from "../../../Server/Services/OnCallReadinessService";
|
|
15
|
+
import AlertSeverityService from "../../../Server/Services/AlertSeverityService";
|
|
16
|
+
import IncidentSeverityService from "../../../Server/Services/IncidentSeverityService";
|
|
17
|
+
import OnCallDutyPolicyEscalationRuleScheduleService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleScheduleService";
|
|
18
|
+
import OnCallDutyPolicyEscalationRuleTeamService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleTeamService";
|
|
19
|
+
import OnCallDutyPolicyEscalationRuleUserService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleUserService";
|
|
20
|
+
import OnCallDutyPolicyScheduleLayerUserService from "../../../Server/Services/OnCallDutyPolicyScheduleLayerUserService";
|
|
21
|
+
import OnCallDutyPolicyService from "../../../Server/Services/OnCallDutyPolicyService";
|
|
22
|
+
import OnCallDutyPolicyUserOverrideService from "../../../Server/Services/OnCallDutyPolicyUserOverrideService";
|
|
23
|
+
import ProjectService from "../../../Server/Services/ProjectService";
|
|
24
|
+
import TeamComplianceSettingService from "../../../Server/Services/TeamComplianceSettingService";
|
|
25
|
+
import TeamMemberService from "../../../Server/Services/TeamMemberService";
|
|
26
|
+
import TeamService from "../../../Server/Services/TeamService";
|
|
27
|
+
import UserCallService from "../../../Server/Services/UserCallService";
|
|
28
|
+
import UserEmailService from "../../../Server/Services/UserEmailService";
|
|
29
|
+
import UserNotificationRuleService from "../../../Server/Services/UserNotificationRuleService";
|
|
30
|
+
import UserPushService from "../../../Server/Services/UserPushService";
|
|
31
|
+
import UserService from "../../../Server/Services/UserService";
|
|
32
|
+
import UserSmsService from "../../../Server/Services/UserSmsService";
|
|
33
|
+
import UserTelegramService from "../../../Server/Services/UserTelegramService";
|
|
34
|
+
import UserWebhookService from "../../../Server/Services/UserWebhookService";
|
|
35
|
+
import UserWhatsAppService from "../../../Server/Services/UserWhatsAppService";
|
|
36
|
+
import {
|
|
37
|
+
ExpressRequest,
|
|
38
|
+
ExpressResponse,
|
|
39
|
+
NextFunction,
|
|
40
|
+
} from "../../../Server/Utils/Express";
|
|
41
|
+
import Response from "../../../Server/Utils/Response";
|
|
42
|
+
import UserCall from "../../../Models/DatabaseModels/UserCall";
|
|
43
|
+
import UserEmail from "../../../Models/DatabaseModels/UserEmail";
|
|
44
|
+
import UserPush from "../../../Models/DatabaseModels/UserPush";
|
|
45
|
+
import UserSMS from "../../../Models/DatabaseModels/UserSMS";
|
|
46
|
+
import UserTelegram from "../../../Models/DatabaseModels/UserTelegram";
|
|
47
|
+
import UserWebhook from "../../../Models/DatabaseModels/UserWebhook";
|
|
48
|
+
import UserWhatsApp from "../../../Models/DatabaseModels/UserWhatsApp";
|
|
49
|
+
import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
50
|
+
import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
|
|
51
|
+
import Dictionary from "../../../Types/Dictionary";
|
|
52
|
+
import Email from "../../../Types/Email";
|
|
53
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
54
|
+
import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
|
|
55
|
+
import NotificationRuleType from "../../../Types/NotificationRule/NotificationRuleType";
|
|
56
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
57
|
+
import Permission, {
|
|
58
|
+
UserPermission,
|
|
59
|
+
UserTenantAccessPermission,
|
|
60
|
+
} from "../../../Types/Permission";
|
|
61
|
+
import Phone from "../../../Types/Phone";
|
|
62
|
+
import ComplianceRuleType from "../../../Types/Team/ComplianceRuleType";
|
|
63
|
+
import {
|
|
64
|
+
afterEach,
|
|
65
|
+
beforeAll,
|
|
66
|
+
beforeEach,
|
|
67
|
+
describe,
|
|
68
|
+
expect,
|
|
69
|
+
test,
|
|
70
|
+
} from "@jest/globals";
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
* The HTTP face of Phase 2, plus the TeamComplianceService rebuild that now sits
|
|
74
|
+
* behind it.
|
|
75
|
+
*
|
|
76
|
+
* Two different levels of test live in this file on purpose, because the two
|
|
77
|
+
* risks are different:
|
|
78
|
+
*
|
|
79
|
+
* TRANSPORT AND AUTHORISATION are tested against a stubbed
|
|
80
|
+
* OnCallReadinessService. What matters there is the shape of the payload and
|
|
81
|
+
* the order of the guards - specifically, that a caller is refused BEFORE any
|
|
82
|
+
* readiness work happens, and that "this policy is in another project", "this
|
|
83
|
+
* policy does not exist" and "this user is not in your project" are all
|
|
84
|
+
* answered identically so the endpoints cannot be used to enumerate ids
|
|
85
|
+
* across tenants.
|
|
86
|
+
*
|
|
87
|
+
* MASKING AND THE COMPLIANCE REBUILD are tested end to end, with the REAL
|
|
88
|
+
* OnCallReadinessService and only the database-facing services stubbed. A
|
|
89
|
+
* masking test that stubs the service it is testing proves nothing: the whole
|
|
90
|
+
* claim is that a raw phone number put into the database cannot come out of
|
|
91
|
+
* the HTTP response, and the only way to test that claim is to put a raw phone
|
|
92
|
+
* number in one end and read the JSON out of the other. The same reasoning
|
|
93
|
+
* applies to the compliance rebuild - TeamComplianceServiceBehaviour.test.ts
|
|
94
|
+
* already pins its mapping with readiness stubbed, so what is left worth
|
|
95
|
+
* proving is that the real seam between the two behaves, which is exactly
|
|
96
|
+
* where the four defects used to live.
|
|
97
|
+
*
|
|
98
|
+
* These routes are mounted with UserMiddleware.getUserMiddleware, which admits
|
|
99
|
+
* anonymous callers as UserType.Public and takes the project from a
|
|
100
|
+
* caller-supplied `tenantid` header, and everything underneath reads with
|
|
101
|
+
* isRoot: true. The handlers are therefore the only gate there is, which is why
|
|
102
|
+
* a disproportionate share of this file is spent on who gets refused.
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
jest.mock("../../../Server/Utils/Express", () => {
|
|
106
|
+
return {
|
|
107
|
+
getRouter: () => {
|
|
108
|
+
return mockRouter;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
jest.mock("../../../Server/Utils/Response", () => {
|
|
114
|
+
return {
|
|
115
|
+
sendJsonObjectResponse: jest.fn().mockImplementation((...args: []) => {
|
|
116
|
+
return args;
|
|
117
|
+
}),
|
|
118
|
+
sendJsonArrayResponse: jest.fn().mockImplementation((...args: []) => {
|
|
119
|
+
return args;
|
|
120
|
+
}),
|
|
121
|
+
sendEntityArrayResponse: jest.fn().mockImplementation((...args: []) => {
|
|
122
|
+
return args;
|
|
123
|
+
}),
|
|
124
|
+
sendEntityResponse: jest.fn().mockImplementation((...args: []) => {
|
|
125
|
+
return args;
|
|
126
|
+
}),
|
|
127
|
+
sendEmptySuccessResponse: jest.fn(),
|
|
128
|
+
sendErrorResponse: jest.fn().mockImplementation((...args: []) => {
|
|
129
|
+
return args;
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const POLICY_ROUTE: string = "/on-call-readiness/policy/:policyId";
|
|
135
|
+
const PROJECT_ROUTE: string = "/on-call-readiness/project";
|
|
136
|
+
const USER_ROUTE: string = "/on-call-readiness/user/:userId";
|
|
137
|
+
const SETUP_REMINDER_ROUTE: string = "/on-call-readiness/send-setup-reminder";
|
|
138
|
+
const COMPLIANCE_ROUTE: string = "/team/compliance-status/:teamId";
|
|
139
|
+
|
|
140
|
+
/*
|
|
141
|
+
* The message every authorisation refusal on these routes carries. Asserting on
|
|
142
|
+
* the literal is the only way to pin the property that matters: "wrong project"
|
|
143
|
+
* and "does not exist" must be indistinguishable to the caller.
|
|
144
|
+
*/
|
|
145
|
+
const REFUSAL: string = "You are not authorized to access this project's data.";
|
|
146
|
+
|
|
147
|
+
/** Enough of a findBy argument to assert on, without importing FindBy generics. */
|
|
148
|
+
interface CapturedFindBy {
|
|
149
|
+
query: Record<string, unknown>;
|
|
150
|
+
select?: Record<string, unknown> | undefined;
|
|
151
|
+
limit?: number | undefined;
|
|
152
|
+
skip?: number | undefined;
|
|
153
|
+
props?: { isRoot?: boolean | undefined } | undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
* Helpers.ts keeps its Route type private; this is the slice of it this file
|
|
158
|
+
* uses, so a change to the harness's internals cannot silently retype these
|
|
159
|
+
* assertions.
|
|
160
|
+
*/
|
|
161
|
+
interface RegisteredRoute {
|
|
162
|
+
method: string;
|
|
163
|
+
uri: string;
|
|
164
|
+
middleware: unknown;
|
|
165
|
+
handlerFunction: (
|
|
166
|
+
req: ExpressRequest,
|
|
167
|
+
res: ExpressResponse,
|
|
168
|
+
next: NextFunction,
|
|
169
|
+
) => void | Promise<void>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface RouteCallResult {
|
|
173
|
+
thrownToNext: unknown;
|
|
174
|
+
nextCallCount: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A minimal user row, as UserService.findBy would hand one back. */
|
|
178
|
+
interface StubUser {
|
|
179
|
+
id: ObjectID;
|
|
180
|
+
name: string;
|
|
181
|
+
email: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A minimal severity row - the shape both severity services return here. */
|
|
185
|
+
interface StubSeverity {
|
|
186
|
+
id: ObjectID;
|
|
187
|
+
name: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildMemberProps(data: {
|
|
191
|
+
projectId: ObjectID;
|
|
192
|
+
userId: ObjectID;
|
|
193
|
+
}): DatabaseCommonInteractionProps {
|
|
194
|
+
const memberPermission: UserPermission = {
|
|
195
|
+
_type: "UserPermission",
|
|
196
|
+
permission: Permission.ProjectMember,
|
|
197
|
+
labelIds: [],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const tenantPermission: UserTenantAccessPermission = {
|
|
201
|
+
_type: "UserTenantAccessPermission",
|
|
202
|
+
projectId: data.projectId,
|
|
203
|
+
permissions: [memberPermission],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const permissionMap: Dictionary<UserTenantAccessPermission> = {};
|
|
207
|
+
permissionMap[data.projectId.toString()] = tenantPermission;
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
tenantId: data.projectId,
|
|
211
|
+
userId: data.userId,
|
|
212
|
+
userTenantAccessPermission: permissionMap,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function routes(): Array<RegisteredRoute> {
|
|
217
|
+
return mockRouter.routes as unknown as Array<RegisteredRoute>;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function routeFor(uri: string): RegisteredRoute {
|
|
221
|
+
const route: RegisteredRoute | undefined = routes().find(
|
|
222
|
+
(candidate: RegisteredRoute): boolean => {
|
|
223
|
+
return candidate.method === "GET" && candidate.uri === uri;
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (!route) {
|
|
228
|
+
throw new Error(`No GET route registered for ${uri}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return route;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function callGetRoute(data: {
|
|
235
|
+
uri: string;
|
|
236
|
+
params?: Dictionary<string> | undefined;
|
|
237
|
+
query?: Dictionary<string> | undefined;
|
|
238
|
+
}): Promise<RouteCallResult> {
|
|
239
|
+
const req: ExpressRequest = {
|
|
240
|
+
params: data.params || {},
|
|
241
|
+
query: data.query || {},
|
|
242
|
+
body: {},
|
|
243
|
+
headers: {},
|
|
244
|
+
} as unknown as ExpressRequest;
|
|
245
|
+
|
|
246
|
+
const res: ExpressResponse = {
|
|
247
|
+
send: jest.fn(),
|
|
248
|
+
json: jest.fn(),
|
|
249
|
+
status: jest.fn().mockReturnThis(),
|
|
250
|
+
} as unknown as ExpressResponse;
|
|
251
|
+
|
|
252
|
+
const next: jest.Mock = jest.fn();
|
|
253
|
+
|
|
254
|
+
await routeFor(data.uri).handlerFunction(
|
|
255
|
+
req,
|
|
256
|
+
res,
|
|
257
|
+
next as unknown as NextFunction,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
thrownToNext: next.mock.calls[0] ? next.mock.calls[0][0] : undefined,
|
|
262
|
+
nextCallCount: next.mock.calls.length,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/*
|
|
267
|
+
* The body the route handed to Response.sendJsonObjectResponse. Typed as a bag
|
|
268
|
+
* of unknowns rather than as the contract's interfaces on purpose: the point of
|
|
269
|
+
* these assertions is what actually crosses the wire, and typing the payload as
|
|
270
|
+
* the thing it is supposed to be would let a missing field type-check its way
|
|
271
|
+
* past the test.
|
|
272
|
+
*/
|
|
273
|
+
function jsonPayload(): Record<string, unknown> {
|
|
274
|
+
const sender: jest.Mock =
|
|
275
|
+
Response.sendJsonObjectResponse as unknown as jest.Mock;
|
|
276
|
+
const calls: Array<Array<unknown>> = sender.mock.calls;
|
|
277
|
+
const last: Array<unknown> | undefined = calls[calls.length - 1];
|
|
278
|
+
|
|
279
|
+
if (!last) {
|
|
280
|
+
throw new Error("The route sent no JSON response");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return last[2] as Record<string, unknown>;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function callsOf(spy: jest.SpyInstance): Array<CapturedFindBy> {
|
|
287
|
+
return spy.mock.calls.map((args: Array<unknown>): CapturedFindBy => {
|
|
288
|
+
return args[0] as CapturedFindBy;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function firstCall(spy: jest.SpyInstance): CapturedFindBy {
|
|
293
|
+
const call: CapturedFindBy | undefined = callsOf(spy)[0];
|
|
294
|
+
|
|
295
|
+
if (!call) {
|
|
296
|
+
throw new Error("Expected the service to have been read at least once");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return call;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let propsSpy: jest.SpyInstance;
|
|
303
|
+
|
|
304
|
+
// Every database-facing read the readiness / compliance path can make.
|
|
305
|
+
let policyFindOneById: jest.SpyInstance;
|
|
306
|
+
let escalationUserFindBy: jest.SpyInstance;
|
|
307
|
+
let escalationTeamFindBy: jest.SpyInstance;
|
|
308
|
+
let escalationScheduleFindBy: jest.SpyInstance;
|
|
309
|
+
let scheduleLayerUserFindBy: jest.SpyInstance;
|
|
310
|
+
let userOverrideFindBy: jest.SpyInstance;
|
|
311
|
+
let teamMemberFindBy: jest.SpyInstance;
|
|
312
|
+
let userFindBy: jest.SpyInstance;
|
|
313
|
+
let userPushFindBy: jest.SpyInstance;
|
|
314
|
+
let userEmailFindBy: jest.SpyInstance;
|
|
315
|
+
let userSmsFindBy: jest.SpyInstance;
|
|
316
|
+
let userCallFindBy: jest.SpyInstance;
|
|
317
|
+
let userWhatsAppFindBy: jest.SpyInstance;
|
|
318
|
+
let userTelegramFindBy: jest.SpyInstance;
|
|
319
|
+
let userWebhookFindBy: jest.SpyInstance;
|
|
320
|
+
let notificationRuleFindBy: jest.SpyInstance;
|
|
321
|
+
let incidentSeverityFindBy: jest.SpyInstance;
|
|
322
|
+
let alertSeverityFindBy: jest.SpyInstance;
|
|
323
|
+
let projectFindOneById: jest.SpyInstance;
|
|
324
|
+
let teamFindOneById: jest.SpyInstance;
|
|
325
|
+
let teamFindOneBy: jest.SpyInstance;
|
|
326
|
+
let complianceSettingFindBy: jest.SpyInstance;
|
|
327
|
+
|
|
328
|
+
function everyFindBySpy(): Array<jest.SpyInstance> {
|
|
329
|
+
return [
|
|
330
|
+
escalationUserFindBy,
|
|
331
|
+
escalationTeamFindBy,
|
|
332
|
+
escalationScheduleFindBy,
|
|
333
|
+
scheduleLayerUserFindBy,
|
|
334
|
+
userOverrideFindBy,
|
|
335
|
+
teamMemberFindBy,
|
|
336
|
+
userFindBy,
|
|
337
|
+
userPushFindBy,
|
|
338
|
+
userEmailFindBy,
|
|
339
|
+
userSmsFindBy,
|
|
340
|
+
userCallFindBy,
|
|
341
|
+
userWhatsAppFindBy,
|
|
342
|
+
userTelegramFindBy,
|
|
343
|
+
userWebhookFindBy,
|
|
344
|
+
notificationRuleFindBy,
|
|
345
|
+
incidentSeverityFindBy,
|
|
346
|
+
alertSeverityFindBy,
|
|
347
|
+
complianceSettingFindBy,
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let projectId: ObjectID;
|
|
352
|
+
let otherProjectId: ObjectID;
|
|
353
|
+
let callerUserId: ObjectID;
|
|
354
|
+
let policyId: ObjectID;
|
|
355
|
+
let subjectUserId: ObjectID;
|
|
356
|
+
let teamId: ObjectID;
|
|
357
|
+
|
|
358
|
+
beforeAll(() => {
|
|
359
|
+
/*
|
|
360
|
+
* OnCallReadinessAPI registers its routes when the MODULE is evaluated - it
|
|
361
|
+
* exports a bare router rather than a class - so its three routes are already
|
|
362
|
+
* on mockRouter by the time this runs. TeamComplianceAPI registers in its
|
|
363
|
+
* constructor, so it needs the explicit `new`. Nothing clears
|
|
364
|
+
* mockRouter.routes here for exactly that reason: doing so would throw away
|
|
365
|
+
* the readiness routes, which cannot be re-registered without re-importing
|
|
366
|
+
* the module.
|
|
367
|
+
*/
|
|
368
|
+
new TeamComplianceAPI();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
beforeEach(() => {
|
|
372
|
+
jest.clearAllMocks();
|
|
373
|
+
|
|
374
|
+
/*
|
|
375
|
+
* The readiness service caches summaries and per-user answers for 60s, keyed
|
|
376
|
+
* on project + scope. Ids are regenerated per test so keys would not collide
|
|
377
|
+
* anyway, but a cache that survives between tests is the kind of thing that
|
|
378
|
+
* makes one test's stub answer another test's assertion, so it is cleared
|
|
379
|
+
* explicitly.
|
|
380
|
+
*/
|
|
381
|
+
OnCallReadinessService.clearCache();
|
|
382
|
+
|
|
383
|
+
projectId = ObjectID.generate();
|
|
384
|
+
otherProjectId = ObjectID.generate();
|
|
385
|
+
callerUserId = ObjectID.generate();
|
|
386
|
+
policyId = ObjectID.generate();
|
|
387
|
+
subjectUserId = ObjectID.generate();
|
|
388
|
+
teamId = ObjectID.generate();
|
|
389
|
+
stubEmailMethodId = ObjectID.generate();
|
|
390
|
+
stubTeamId = ObjectID.generate();
|
|
391
|
+
|
|
392
|
+
propsSpy = jest
|
|
393
|
+
.spyOn(CommonAPI, "getDatabaseCommonInteractionProps")
|
|
394
|
+
.mockResolvedValue(
|
|
395
|
+
buildMemberProps({ projectId: projectId, userId: callerUserId }),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
policyFindOneById = jest
|
|
399
|
+
.spyOn(OnCallDutyPolicyService, "findOneById")
|
|
400
|
+
.mockResolvedValue({ id: policyId, projectId: projectId } as never);
|
|
401
|
+
|
|
402
|
+
escalationUserFindBy = jest
|
|
403
|
+
.spyOn(OnCallDutyPolicyEscalationRuleUserService, "findBy")
|
|
404
|
+
.mockResolvedValue([] as never);
|
|
405
|
+
escalationTeamFindBy = jest
|
|
406
|
+
.spyOn(OnCallDutyPolicyEscalationRuleTeamService, "findBy")
|
|
407
|
+
.mockResolvedValue([] as never);
|
|
408
|
+
escalationScheduleFindBy = jest
|
|
409
|
+
.spyOn(OnCallDutyPolicyEscalationRuleScheduleService, "findBy")
|
|
410
|
+
.mockResolvedValue([] as never);
|
|
411
|
+
scheduleLayerUserFindBy = jest
|
|
412
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
413
|
+
.mockResolvedValue([] as never);
|
|
414
|
+
userOverrideFindBy = jest
|
|
415
|
+
.spyOn(OnCallDutyPolicyUserOverrideService, "findBy")
|
|
416
|
+
.mockResolvedValue([] as never);
|
|
417
|
+
|
|
418
|
+
/*
|
|
419
|
+
* A membership row by default: both the route's own
|
|
420
|
+
* assertUserBelongsToProject and the readiness service's identical check read
|
|
421
|
+
* this table, and "the caller is looking at somebody in their own project" is
|
|
422
|
+
* the case most tests are about. The tests that care about the refusal
|
|
423
|
+
* override it with [].
|
|
424
|
+
*/
|
|
425
|
+
teamMemberFindBy = jest
|
|
426
|
+
.spyOn(TeamMemberService, "findBy")
|
|
427
|
+
.mockResolvedValue([{ _id: "tm-1", userId: subjectUserId }] as never);
|
|
428
|
+
|
|
429
|
+
userFindBy = jest.spyOn(UserService, "findBy").mockResolvedValue([] as never);
|
|
430
|
+
userPushFindBy = jest
|
|
431
|
+
.spyOn(UserPushService, "findBy")
|
|
432
|
+
.mockResolvedValue([] as never);
|
|
433
|
+
userEmailFindBy = jest
|
|
434
|
+
.spyOn(UserEmailService, "findBy")
|
|
435
|
+
.mockResolvedValue([] as never);
|
|
436
|
+
userSmsFindBy = jest
|
|
437
|
+
.spyOn(UserSmsService, "findBy")
|
|
438
|
+
.mockResolvedValue([] as never);
|
|
439
|
+
userCallFindBy = jest
|
|
440
|
+
.spyOn(UserCallService, "findBy")
|
|
441
|
+
.mockResolvedValue([] as never);
|
|
442
|
+
userWhatsAppFindBy = jest
|
|
443
|
+
.spyOn(UserWhatsAppService, "findBy")
|
|
444
|
+
.mockResolvedValue([] as never);
|
|
445
|
+
userTelegramFindBy = jest
|
|
446
|
+
.spyOn(UserTelegramService, "findBy")
|
|
447
|
+
.mockResolvedValue([] as never);
|
|
448
|
+
userWebhookFindBy = jest
|
|
449
|
+
.spyOn(UserWebhookService, "findBy")
|
|
450
|
+
.mockResolvedValue([] as never);
|
|
451
|
+
notificationRuleFindBy = jest
|
|
452
|
+
.spyOn(UserNotificationRuleService, "findBy")
|
|
453
|
+
.mockResolvedValue([] as never);
|
|
454
|
+
incidentSeverityFindBy = jest
|
|
455
|
+
.spyOn(IncidentSeverityService, "findBy")
|
|
456
|
+
.mockResolvedValue([] as never);
|
|
457
|
+
alertSeverityFindBy = jest
|
|
458
|
+
.spyOn(AlertSeverityService, "findBy")
|
|
459
|
+
.mockResolvedValue([] as never);
|
|
460
|
+
projectFindOneById = jest
|
|
461
|
+
.spyOn(ProjectService, "findOneById")
|
|
462
|
+
.mockResolvedValue({
|
|
463
|
+
id: projectId,
|
|
464
|
+
disableOnCallNotificationFallback: false,
|
|
465
|
+
enableSmsNotifications: true,
|
|
466
|
+
enableCallNotifications: true,
|
|
467
|
+
enableWhatsAppNotifications: true,
|
|
468
|
+
enableTelegramNotifications: true,
|
|
469
|
+
} as never);
|
|
470
|
+
|
|
471
|
+
/*
|
|
472
|
+
* The team is read TWICE on the compliance route, by two different methods,
|
|
473
|
+
* and both are stubbed because they answer two different questions.
|
|
474
|
+
*
|
|
475
|
+
* findOneById is the route's authorisation read: it fetches the team's own
|
|
476
|
+
* projectId so the handler can refuse a team that belongs to somebody else.
|
|
477
|
+
* The stub therefore carries a projectId - a row without one is refused, which
|
|
478
|
+
* is the behaviour the "foreign team" tests below rely on.
|
|
479
|
+
*
|
|
480
|
+
* findOneBy is the service's own read, scoped to id AND project in the query,
|
|
481
|
+
* so that an in-process caller reaching the service directly cannot resolve a
|
|
482
|
+
* foreign team either.
|
|
483
|
+
*/
|
|
484
|
+
teamFindOneById = jest.spyOn(TeamService, "findOneById").mockResolvedValue({
|
|
485
|
+
_id: teamId.toString(),
|
|
486
|
+
projectId: projectId,
|
|
487
|
+
} as never);
|
|
488
|
+
teamFindOneBy = jest
|
|
489
|
+
.spyOn(TeamService, "findOneBy")
|
|
490
|
+
.mockResolvedValue({ name: "Platform On-Call" } as never);
|
|
491
|
+
complianceSettingFindBy = jest
|
|
492
|
+
.spyOn(TeamComplianceSettingService, "findBy")
|
|
493
|
+
.mockResolvedValue([] as never);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
afterEach(() => {
|
|
497
|
+
jest.restoreAllMocks();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
/*
|
|
501
|
+
* --------------------------------------------------------------------------- *
|
|
502
|
+
* Registration. The contract names three reads and one write; a fifth route, or
|
|
503
|
+
* a missing one, is a contract change and should read as one.
|
|
504
|
+
*
|
|
505
|
+
* The write - POST /send-setup-reminder, added in Phase 4 - is enumerated here
|
|
506
|
+
* with its method so that a read accidentally registered as a write, or the
|
|
507
|
+
* reverse, fails this test rather than being discovered in production. Its own
|
|
508
|
+
* behaviour lives in OnCallSetupReminderAPI.test.ts.
|
|
509
|
+
* ---------------------------------------------------------------------------
|
|
510
|
+
*/
|
|
511
|
+
|
|
512
|
+
describe("OnCallReadinessAPI - route registration", () => {
|
|
513
|
+
test("exports the very router it registered its routes on", () => {
|
|
514
|
+
/*
|
|
515
|
+
* The module is `export default router`, not a class - App mounts it with
|
|
516
|
+
* app.use(prefix, OnCallReadinessAPI). If it ever exported something else,
|
|
517
|
+
* the routes below would be registered on a router nothing is serving.
|
|
518
|
+
*/
|
|
519
|
+
const exported: unknown = OnCallReadinessAPI;
|
|
520
|
+
|
|
521
|
+
expect(exported).toBe(mockRouter);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("registers exactly the contract routes: three reads and one write", () => {
|
|
525
|
+
const readinessUris: Array<string> = routes()
|
|
526
|
+
.filter((route: RegisteredRoute): boolean => {
|
|
527
|
+
return route.uri.startsWith("/on-call-readiness");
|
|
528
|
+
})
|
|
529
|
+
.map((route: RegisteredRoute): string => {
|
|
530
|
+
return `${route.method} ${route.uri}`;
|
|
531
|
+
})
|
|
532
|
+
.sort();
|
|
533
|
+
|
|
534
|
+
expect(readinessUris).toEqual([
|
|
535
|
+
`GET ${POLICY_ROUTE}`,
|
|
536
|
+
`GET ${PROJECT_ROUTE}`,
|
|
537
|
+
`GET ${USER_ROUTE}`,
|
|
538
|
+
`POST ${SETUP_REMINDER_ROUTE}`,
|
|
539
|
+
]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test.each<[string]>([[POLICY_ROUTE], [PROJECT_ROUTE], [USER_ROUTE]])(
|
|
543
|
+
"%s runs behind the user middleware",
|
|
544
|
+
(uri: string) => {
|
|
545
|
+
/*
|
|
546
|
+
* getUserMiddleware is not an authorisation gate - it admits anonymous
|
|
547
|
+
* callers - but it is what populates the request with whatever identity
|
|
548
|
+
* IS present, and without it every caller would arrive at the handler
|
|
549
|
+
* looking anonymous and be refused.
|
|
550
|
+
*/
|
|
551
|
+
expect(routeFor(uri).middleware).toBe(
|
|
552
|
+
UserMiddleware.getUserMiddleware as unknown,
|
|
553
|
+
);
|
|
554
|
+
},
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
/*
|
|
559
|
+
* --------------------------------------------------------------------------- *
|
|
560
|
+
* Transport and authorisation, against a stubbed readiness service.
|
|
561
|
+
* ---------------------------------------------------------------------------
|
|
562
|
+
*/
|
|
563
|
+
|
|
564
|
+
/*
|
|
565
|
+
* The id of the UserEmail ROW the stub summary's one method describes. Held on
|
|
566
|
+
* the module rather than generated inside stubSummary() so an assertion can name
|
|
567
|
+
* the value it expects on the wire, and deliberately NOT equal to subjectUserId:
|
|
568
|
+
* the two are both ObjectIDs hanging off the same row, so confusing them
|
|
569
|
+
* compiles and renders perfectly and is only discovered when a saved rule turns
|
|
570
|
+
* out to reference a User where a UserEmail belongs.
|
|
571
|
+
*/
|
|
572
|
+
let stubEmailMethodId: ObjectID;
|
|
573
|
+
|
|
574
|
+
/*
|
|
575
|
+
* The id of the team the stub summary's responder is paged through. Held on the
|
|
576
|
+
* module for the same reason as stubEmailMethodId - an assertion has to be able
|
|
577
|
+
* to name the exact value it expects to find flattened on the wire, because this
|
|
578
|
+
* id is what the readiness table's team filter submits.
|
|
579
|
+
*/
|
|
580
|
+
let stubTeamId: ObjectID;
|
|
581
|
+
|
|
582
|
+
function stubSummary(): ReadinessSummary {
|
|
583
|
+
const severityId: ObjectID = ObjectID.generate();
|
|
584
|
+
const pictureId: ObjectID = ObjectID.generate();
|
|
585
|
+
|
|
586
|
+
const user: UserReadiness = {
|
|
587
|
+
userId: subjectUserId,
|
|
588
|
+
userName: "Ada Lovelace",
|
|
589
|
+
userEmail: "ada@example.com",
|
|
590
|
+
userProfilePictureId: pictureId,
|
|
591
|
+
status: ReadinessStatus.PartiallyReady,
|
|
592
|
+
methods: [
|
|
593
|
+
{
|
|
594
|
+
methodId: stubEmailMethodId,
|
|
595
|
+
methodType: ReadinessMethodType.Email,
|
|
596
|
+
maskedIdentifier: `a${IDENTIFIER_MASK}@example.com`,
|
|
597
|
+
isVerified: true,
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
coverage: [
|
|
601
|
+
{
|
|
602
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
603
|
+
severityId: severityId,
|
|
604
|
+
severityName: "Critical",
|
|
605
|
+
hasRule: true,
|
|
606
|
+
isOptOut: false,
|
|
607
|
+
},
|
|
608
|
+
/*
|
|
609
|
+
* A cell with NO severity. The service does not emit these today, but the
|
|
610
|
+
* contract allows them for the two go-on/off-call rule types and the
|
|
611
|
+
* serialiser has to carry the distinction between "no severity" and "the
|
|
612
|
+
* severity failed to load" rather than dropping the field.
|
|
613
|
+
*/
|
|
614
|
+
{
|
|
615
|
+
ruleType: NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
616
|
+
severityId: undefined,
|
|
617
|
+
severityName: undefined,
|
|
618
|
+
hasRule: false,
|
|
619
|
+
isOptOut: false,
|
|
620
|
+
},
|
|
621
|
+
],
|
|
622
|
+
reasons: ["No rules for Sev4 incidents - pages fall back to Push, Email"],
|
|
623
|
+
reachedVia: [ResponderSource.Team, ResponderSource.Override],
|
|
624
|
+
/*
|
|
625
|
+
* A team, because this responder is reached through one. The serialiser has
|
|
626
|
+
* to flatten the id the same way it flattens every other id here - a team
|
|
627
|
+
* that reached the browser as ObjectID.toJSON()'s `{_type, value}` wrapper
|
|
628
|
+
* would populate the readiness table's team filter with options whose value
|
|
629
|
+
* matches no row.
|
|
630
|
+
*/
|
|
631
|
+
teams: [{ _id: stubTeamId, name: "Platform" }],
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
projectId: projectId,
|
|
636
|
+
onCallDutyPolicyId: policyId,
|
|
637
|
+
/*
|
|
638
|
+
* Deliberately inconsistent with `users` below, which holds one row: these
|
|
639
|
+
* are WHOLE-SCOPE counts and the users array is a page of that scope, so a
|
|
640
|
+
* serialiser that recomputed either from the other would be caught here.
|
|
641
|
+
*/
|
|
642
|
+
readyCount: 3,
|
|
643
|
+
partiallyReadyCount: 1,
|
|
644
|
+
notReachableCount: 2,
|
|
645
|
+
isFallbackEnabled: true,
|
|
646
|
+
isTruncated: false,
|
|
647
|
+
users: [user],
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** A summary carrying `count` responders, for the paging assertions. */
|
|
652
|
+
function stubSummaryWithUsers(count: number): ReadinessSummary {
|
|
653
|
+
const summary: ReadinessSummary = stubSummary();
|
|
654
|
+
const template: UserReadiness = summary.users[0]!;
|
|
655
|
+
|
|
656
|
+
summary.users = [];
|
|
657
|
+
|
|
658
|
+
for (let index: number = 0; index < count; index++) {
|
|
659
|
+
summary.users.push({
|
|
660
|
+
...template,
|
|
661
|
+
userId: ObjectID.generate(),
|
|
662
|
+
userName: `Responder ${index}`,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return summary;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
describe("GET /on-call-readiness/policy/:policyId", () => {
|
|
670
|
+
let policySpy: jest.SpyInstance;
|
|
671
|
+
|
|
672
|
+
beforeEach(() => {
|
|
673
|
+
policySpy = jest
|
|
674
|
+
.spyOn(OnCallReadinessService, "getReadinessForPolicy")
|
|
675
|
+
.mockResolvedValue(stubSummary() as never);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("returns the ReadinessSummary contract, with every id flattened to a string", async () => {
|
|
679
|
+
const summary: ReadinessSummary = stubSummary();
|
|
680
|
+
policySpy.mockResolvedValue(summary as never);
|
|
681
|
+
|
|
682
|
+
const result: RouteCallResult = await callGetRoute({
|
|
683
|
+
uri: POLICY_ROUTE,
|
|
684
|
+
params: { policyId: policyId.toString() },
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
expect(result.nextCallCount).toBe(0);
|
|
688
|
+
expect(policySpy).toHaveBeenCalledTimes(1);
|
|
689
|
+
expect(policySpy.mock.calls[0]).toEqual([policyId, projectId]);
|
|
690
|
+
|
|
691
|
+
const payload: Record<string, unknown> = jsonPayload();
|
|
692
|
+
|
|
693
|
+
/*
|
|
694
|
+
* The key set, asserted exhaustively. A field quietly added to the payload
|
|
695
|
+
* is how an unmasked value gets shipped, and a field quietly removed is how
|
|
696
|
+
* the dashboard starts rendering blanks; both should fail here.
|
|
697
|
+
*/
|
|
698
|
+
expect(Object.keys(payload).sort()).toEqual([
|
|
699
|
+
"hasMore",
|
|
700
|
+
"isFallbackEnabled",
|
|
701
|
+
"isTruncated",
|
|
702
|
+
"limit",
|
|
703
|
+
"notReachableCount",
|
|
704
|
+
"onCallDutyPolicyId",
|
|
705
|
+
"partiallyReadyCount",
|
|
706
|
+
"projectId",
|
|
707
|
+
"readyCount",
|
|
708
|
+
"skip",
|
|
709
|
+
"totalCount",
|
|
710
|
+
"users",
|
|
711
|
+
]);
|
|
712
|
+
|
|
713
|
+
expect(payload["projectId"]).toBe(projectId.toString());
|
|
714
|
+
expect(payload["onCallDutyPolicyId"]).toBe(policyId.toString());
|
|
715
|
+
expect(payload["readyCount"]).toBe(3);
|
|
716
|
+
expect(payload["partiallyReadyCount"]).toBe(1);
|
|
717
|
+
expect(payload["notReachableCount"]).toBe(2);
|
|
718
|
+
/*
|
|
719
|
+
* The two scope-level facts. isFallbackEnabled is what stops a UI promising
|
|
720
|
+
* "nothing is dropped" in a project where pages with no matching rule ARE
|
|
721
|
+
* dropped; isTruncated is what stops it promising a complete answer when the
|
|
722
|
+
* reads behind the summary hit their ceiling. Both must survive the
|
|
723
|
+
* serialiser, on every page, or the UI cannot tell either story.
|
|
724
|
+
*/
|
|
725
|
+
expect(payload["isFallbackEnabled"]).toBe(true);
|
|
726
|
+
expect(payload["isTruncated"]).toBe(false);
|
|
727
|
+
|
|
728
|
+
const users: Array<Record<string, unknown>> = payload["users"] as Array<
|
|
729
|
+
Record<string, unknown>
|
|
730
|
+
>;
|
|
731
|
+
expect(users).toHaveLength(1);
|
|
732
|
+
|
|
733
|
+
const user: Record<string, unknown> = users[0]!;
|
|
734
|
+
expect(Object.keys(user).sort()).toEqual([
|
|
735
|
+
"coverage",
|
|
736
|
+
"methods",
|
|
737
|
+
"reachedVia",
|
|
738
|
+
"reasons",
|
|
739
|
+
"status",
|
|
740
|
+
"teams",
|
|
741
|
+
"userEmail",
|
|
742
|
+
"userId",
|
|
743
|
+
"userName",
|
|
744
|
+
"userProfilePictureId",
|
|
745
|
+
]);
|
|
746
|
+
|
|
747
|
+
expect(user["userId"]).toBe(subjectUserId.toString());
|
|
748
|
+
expect(user["userName"]).toBe("Ada Lovelace");
|
|
749
|
+
expect(user["userEmail"]).toBe("ada@example.com");
|
|
750
|
+
expect(typeof user["userProfilePictureId"]).toBe("string");
|
|
751
|
+
expect(user["status"]).toBe(ReadinessStatus.PartiallyReady);
|
|
752
|
+
expect(user["reachedVia"]).toEqual([
|
|
753
|
+
ResponderSource.Team,
|
|
754
|
+
ResponderSource.Override,
|
|
755
|
+
]);
|
|
756
|
+
expect(user["reasons"]).toEqual([
|
|
757
|
+
"No rules for Sev4 incidents - pages fall back to Push, Email",
|
|
758
|
+
]);
|
|
759
|
+
|
|
760
|
+
/*
|
|
761
|
+
* Four fields and no fifth. `methods` is the part of this payload that
|
|
762
|
+
* describes rows an administrator is NOT permitted to read - the seven
|
|
763
|
+
* method models are owner-scoped precisely because their columns are the raw
|
|
764
|
+
* phone number, the webhook bearer url, the push token, the telegram chat id
|
|
765
|
+
* and the verification code - so the wire shape is asserted exhaustively
|
|
766
|
+
* rather than field by field. A field added to ReadinessMethod is a field
|
|
767
|
+
* that ships to every administrator of the project, and it should have to
|
|
768
|
+
* pass through here on the way.
|
|
769
|
+
*/
|
|
770
|
+
expect(user["methods"]).toEqual([
|
|
771
|
+
{
|
|
772
|
+
methodId: stubEmailMethodId.toString(),
|
|
773
|
+
methodType: ReadinessMethodType.Email,
|
|
774
|
+
maskedIdentifier: `a${IDENTIFIER_MASK}@example.com`,
|
|
775
|
+
isVerified: true,
|
|
776
|
+
},
|
|
777
|
+
]);
|
|
778
|
+
|
|
779
|
+
/*
|
|
780
|
+
* And flattened to a plain string like every other id in this payload,
|
|
781
|
+
* rather than handed over as ObjectID.toJSON()'s
|
|
782
|
+
* `{ _type: "ObjectID", value: "..." }`. The rule form puts this value
|
|
783
|
+
* straight into userEmailId; a caller that received the wrapper shape and
|
|
784
|
+
* submitted it would be writing an object where a foreign key goes.
|
|
785
|
+
*/
|
|
786
|
+
const method: Record<string, unknown> = (
|
|
787
|
+
user["methods"] as Array<Record<string, unknown>>
|
|
788
|
+
)[0]!;
|
|
789
|
+
expect(typeof method["methodId"]).toBe("string");
|
|
790
|
+
expect(method["methodId"]).not.toBe(subjectUserId.toString());
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
/*
|
|
794
|
+
* The teams that page this responder, flattened the same way every other id
|
|
795
|
+
* here is.
|
|
796
|
+
*
|
|
797
|
+
* This one has a specific consumer: the readiness table's team filter builds
|
|
798
|
+
* its options out of these, and submits the id it was given. A team that
|
|
799
|
+
* arrived as ObjectID.toJSON()'s `{ _type: "ObjectID", value: "..." }` would
|
|
800
|
+
* populate a dropdown whose values match no row, and the filter would silently
|
|
801
|
+
* answer with an empty table for every option.
|
|
802
|
+
*/
|
|
803
|
+
test("a responder's teams carry a flat string id and a name", async () => {
|
|
804
|
+
await callGetRoute({
|
|
805
|
+
uri: POLICY_ROUTE,
|
|
806
|
+
params: { policyId: policyId.toString() },
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
const users: Array<Record<string, unknown>> = jsonPayload()[
|
|
810
|
+
"users"
|
|
811
|
+
] as Array<Record<string, unknown>>;
|
|
812
|
+
|
|
813
|
+
expect(users[0]!["teams"]).toEqual([
|
|
814
|
+
{
|
|
815
|
+
_id: stubTeamId.toString(),
|
|
816
|
+
name: "Platform",
|
|
817
|
+
},
|
|
818
|
+
]);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("the team id on the wire is a string, not an ObjectID wrapper", async () => {
|
|
822
|
+
await callGetRoute({
|
|
823
|
+
uri: POLICY_ROUTE,
|
|
824
|
+
params: { policyId: policyId.toString() },
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const users: Array<Record<string, unknown>> = jsonPayload()[
|
|
828
|
+
"users"
|
|
829
|
+
] as Array<Record<string, unknown>>;
|
|
830
|
+
|
|
831
|
+
const team: Record<string, unknown> = (
|
|
832
|
+
users[0]!["teams"] as Array<Record<string, unknown>>
|
|
833
|
+
)[0]!;
|
|
834
|
+
|
|
835
|
+
expect(typeof team["_id"]).toBe("string");
|
|
836
|
+
expect(team["_id"]).not.toBe(subjectUserId.toString());
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("a coverage cell carries its severity id as a string, and undefined when it has none", async () => {
|
|
840
|
+
await callGetRoute({
|
|
841
|
+
uri: POLICY_ROUTE,
|
|
842
|
+
params: { policyId: policyId.toString() },
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const users: Array<Record<string, unknown>> = jsonPayload()[
|
|
846
|
+
"users"
|
|
847
|
+
] as Array<Record<string, unknown>>;
|
|
848
|
+
const coverage: Array<Record<string, unknown>> = users[0]![
|
|
849
|
+
"coverage"
|
|
850
|
+
] as Array<Record<string, unknown>>;
|
|
851
|
+
|
|
852
|
+
expect(coverage).toHaveLength(2);
|
|
853
|
+
expect(Object.keys(coverage[0]!).sort()).toEqual([
|
|
854
|
+
"hasRule",
|
|
855
|
+
"isOptOut",
|
|
856
|
+
"ruleType",
|
|
857
|
+
"severityId",
|
|
858
|
+
"severityName",
|
|
859
|
+
]);
|
|
860
|
+
expect(coverage[0]!["ruleType"]).toBe(
|
|
861
|
+
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
862
|
+
);
|
|
863
|
+
expect(typeof coverage[0]!["severityId"]).toBe("string");
|
|
864
|
+
expect(coverage[0]!["severityName"]).toBe("Critical");
|
|
865
|
+
expect(coverage[0]!["hasRule"]).toBe(true);
|
|
866
|
+
expect(coverage[0]!["isOptOut"]).toBe(false);
|
|
867
|
+
|
|
868
|
+
// The severity-less cell keeps the key, so "none" and "missing" stay apart.
|
|
869
|
+
expect(coverage[1]!).toHaveProperty("severityId");
|
|
870
|
+
expect(coverage[1]!["severityId"]).toBeUndefined();
|
|
871
|
+
expect(coverage[1]!["ruleType"]).toBe(
|
|
872
|
+
NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
873
|
+
);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("takes the owning project from the policy row, read as root", async () => {
|
|
877
|
+
await callGetRoute({
|
|
878
|
+
uri: POLICY_ROUTE,
|
|
879
|
+
params: { policyId: policyId.toString() },
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
expect(policyFindOneById).toHaveBeenCalledTimes(1);
|
|
883
|
+
const read: {
|
|
884
|
+
id: ObjectID;
|
|
885
|
+
select: Record<string, unknown>;
|
|
886
|
+
props: { isRoot?: boolean | undefined };
|
|
887
|
+
} = policyFindOneById.mock.calls[0]![0] as {
|
|
888
|
+
id: ObjectID;
|
|
889
|
+
select: Record<string, unknown>;
|
|
890
|
+
props: { isRoot?: boolean | undefined };
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
expect(read.id.toString()).toBe(policyId.toString());
|
|
894
|
+
expect(read.select["projectId"]).toBe(true);
|
|
895
|
+
expect(read.props.isRoot).toBe(true);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("refuses an unauthenticated caller before reading anything", async () => {
|
|
899
|
+
propsSpy.mockResolvedValue({} as never);
|
|
900
|
+
|
|
901
|
+
const result: RouteCallResult = await callGetRoute({
|
|
902
|
+
uri: POLICY_ROUTE,
|
|
903
|
+
params: { policyId: policyId.toString() },
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
907
|
+
expect(policyFindOneById).not.toHaveBeenCalled();
|
|
908
|
+
expect(policySpy).not.toHaveBeenCalled();
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("refuses a public caller that merely supplies a tenantid header", async () => {
|
|
912
|
+
/*
|
|
913
|
+
* The exact shape getUserMiddleware produces for an anonymous request that
|
|
914
|
+
* carried a `tenantid` header: a project id, no user, no permissions. If
|
|
915
|
+
* this got through, one header would be the whole authentication story for
|
|
916
|
+
* every responder's configuration in that project.
|
|
917
|
+
*/
|
|
918
|
+
propsSpy.mockResolvedValue({
|
|
919
|
+
tenantId: projectId,
|
|
920
|
+
userId: undefined,
|
|
921
|
+
userTenantAccessPermission: undefined,
|
|
922
|
+
} as never);
|
|
923
|
+
|
|
924
|
+
const result: RouteCallResult = await callGetRoute({
|
|
925
|
+
uri: POLICY_ROUTE,
|
|
926
|
+
params: { policyId: policyId.toString() },
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
930
|
+
expect(policyFindOneById).not.toHaveBeenCalled();
|
|
931
|
+
expect(policySpy).not.toHaveBeenCalled();
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test("a member of one project cannot read another project's policy", async () => {
|
|
935
|
+
policyFindOneById.mockResolvedValue({
|
|
936
|
+
id: policyId,
|
|
937
|
+
projectId: otherProjectId,
|
|
938
|
+
} as never);
|
|
939
|
+
|
|
940
|
+
const result: RouteCallResult = await callGetRoute({
|
|
941
|
+
uri: POLICY_ROUTE,
|
|
942
|
+
params: { policyId: policyId.toString() },
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
946
|
+
expect((result.thrownToNext as NotAuthorizedException).message).toBe(
|
|
947
|
+
REFUSAL,
|
|
948
|
+
);
|
|
949
|
+
expect(policySpy).not.toHaveBeenCalled();
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
test("an unknown policy id is refused, and refused indistinguishably from a foreign one", async () => {
|
|
953
|
+
/*
|
|
954
|
+
* The dangerous alternative is not a crash - it is a 200 carrying an empty
|
|
955
|
+
* summary, because every readiness query is projectId-scoped and would
|
|
956
|
+
* simply match nothing. "0 responders, nothing wrong" is the worst possible
|
|
957
|
+
* answer to "is this policy safe to rely on?". The second assertion is the
|
|
958
|
+
* anti-enumeration one: identical wording means a caller cannot use this
|
|
959
|
+
* endpoint to learn which policy ids exist in other projects.
|
|
960
|
+
*/
|
|
961
|
+
policyFindOneById.mockResolvedValue(null as never);
|
|
962
|
+
|
|
963
|
+
const result: RouteCallResult = await callGetRoute({
|
|
964
|
+
uri: POLICY_ROUTE,
|
|
965
|
+
params: { policyId: policyId.toString() },
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
969
|
+
expect((result.thrownToNext as NotAuthorizedException).message).toBe(
|
|
970
|
+
REFUSAL,
|
|
971
|
+
);
|
|
972
|
+
expect(policySpy).not.toHaveBeenCalled();
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("a malformed policy id is rejected as bad data, not turned into a query", async () => {
|
|
976
|
+
const result: RouteCallResult = await callGetRoute({
|
|
977
|
+
uri: POLICY_ROUTE,
|
|
978
|
+
params: { policyId: "not-a-uuid" },
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
982
|
+
expect(policyFindOneById).not.toHaveBeenCalled();
|
|
983
|
+
expect(policySpy).not.toHaveBeenCalled();
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test("a failure inside the readiness service reaches next(), not the client as a crash", async () => {
|
|
987
|
+
policySpy.mockRejectedValue(
|
|
988
|
+
new BadDataException("On-call duty policy not found") as never,
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
const result: RouteCallResult = await callGetRoute({
|
|
992
|
+
uri: POLICY_ROUTE,
|
|
993
|
+
params: { policyId: policyId.toString() },
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
expect(result.nextCallCount).toBe(1);
|
|
997
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
998
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
describe("GET /on-call-readiness/project", () => {
|
|
1003
|
+
let projectSpy: jest.SpyInstance;
|
|
1004
|
+
|
|
1005
|
+
beforeEach(() => {
|
|
1006
|
+
const summary: ReadinessSummary = stubSummary();
|
|
1007
|
+
summary.onCallDutyPolicyId = undefined;
|
|
1008
|
+
|
|
1009
|
+
projectSpy = jest
|
|
1010
|
+
.spyOn(OnCallReadinessService, "getReadinessForProject")
|
|
1011
|
+
.mockResolvedValue(summary as never);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("returns the summary for the caller's own project", async () => {
|
|
1015
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1016
|
+
uri: PROJECT_ROUTE,
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
expect(result.nextCallCount).toBe(0);
|
|
1020
|
+
expect(projectSpy).toHaveBeenCalledTimes(1);
|
|
1021
|
+
expect(projectSpy.mock.calls[0]).toEqual([projectId]);
|
|
1022
|
+
|
|
1023
|
+
const payload: Record<string, unknown> = jsonPayload();
|
|
1024
|
+
expect(payload["projectId"]).toBe(projectId.toString());
|
|
1025
|
+
// No policy scope on this route - the key stays, carrying undefined.
|
|
1026
|
+
expect(payload).toHaveProperty("onCallDutyPolicyId");
|
|
1027
|
+
expect(payload["onCallDutyPolicyId"]).toBeUndefined();
|
|
1028
|
+
expect(payload["users"]).toHaveLength(1);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
test("the project comes from the authenticated tenant, never from the query string", async () => {
|
|
1032
|
+
/*
|
|
1033
|
+
* There is no path parameter to tamper with on this route, so the only
|
|
1034
|
+
* plausible attack is a query parameter the handler might one day start
|
|
1035
|
+
* trusting. Pinning it now costs nothing and makes the regression loud.
|
|
1036
|
+
*/
|
|
1037
|
+
await callGetRoute({
|
|
1038
|
+
uri: PROJECT_ROUTE,
|
|
1039
|
+
query: { projectId: otherProjectId.toString() },
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
expect(projectSpy.mock.calls[0]).toEqual([projectId]);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
test("refuses an unauthenticated caller", async () => {
|
|
1046
|
+
propsSpy.mockResolvedValue({} as never);
|
|
1047
|
+
|
|
1048
|
+
const result: RouteCallResult = await callGetRoute({ uri: PROJECT_ROUTE });
|
|
1049
|
+
|
|
1050
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
1051
|
+
expect(projectSpy).not.toHaveBeenCalled();
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test("refuses a caller whose tenantid header names a project they are not in", async () => {
|
|
1055
|
+
/*
|
|
1056
|
+
* A real, logged-in user - the header is the only thing that is a lie. This
|
|
1057
|
+
* is the whole attack on a route with no path parameter: the project is
|
|
1058
|
+
* taken from a value the caller supplies, so the permission map has to be
|
|
1059
|
+
* consulted for that exact project rather than merely being present.
|
|
1060
|
+
*/
|
|
1061
|
+
propsSpy.mockResolvedValue({
|
|
1062
|
+
tenantId: projectId,
|
|
1063
|
+
userId: callerUserId,
|
|
1064
|
+
userTenantAccessPermission: {},
|
|
1065
|
+
} as never);
|
|
1066
|
+
|
|
1067
|
+
const result: RouteCallResult = await callGetRoute({ uri: PROJECT_ROUTE });
|
|
1068
|
+
|
|
1069
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
1070
|
+
expect(projectSpy).not.toHaveBeenCalled();
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
/*
|
|
1075
|
+
* --------------------------------------------------------------------------- *
|
|
1076
|
+
* Paging on the two summary routes.
|
|
1077
|
+
*
|
|
1078
|
+
* A summary carries one coverage cell per (rule type, severity) pair per
|
|
1079
|
+
* responder, so a large project serialises to tens of megabytes. The response is
|
|
1080
|
+
* therefore bounded - and the whole risk of bounding it is that a client cannot
|
|
1081
|
+
* tell a short page from a complete list. Everything below is about the
|
|
1082
|
+
* difference between those two things.
|
|
1083
|
+
* ---------------------------------------------------------------------------
|
|
1084
|
+
*/
|
|
1085
|
+
|
|
1086
|
+
describe("paging the readiness summaries", () => {
|
|
1087
|
+
let projectSpy: jest.SpyInstance;
|
|
1088
|
+
let policySpy: jest.SpyInstance;
|
|
1089
|
+
|
|
1090
|
+
beforeEach(() => {
|
|
1091
|
+
projectSpy = jest
|
|
1092
|
+
.spyOn(OnCallReadinessService, "getReadinessForProject")
|
|
1093
|
+
.mockResolvedValue(stubSummaryWithUsers(250) as never);
|
|
1094
|
+
policySpy = jest
|
|
1095
|
+
.spyOn(OnCallReadinessService, "getReadinessForPolicy")
|
|
1096
|
+
.mockResolvedValue(stubSummaryWithUsers(250) as never);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
test("defaults to one hundred responders, and says so", async () => {
|
|
1100
|
+
await callGetRoute({ uri: PROJECT_ROUTE });
|
|
1101
|
+
|
|
1102
|
+
const payload: Record<string, unknown> = jsonPayload();
|
|
1103
|
+
|
|
1104
|
+
expect(payload["users"]).toHaveLength(100);
|
|
1105
|
+
expect(payload["limit"]).toBe(100);
|
|
1106
|
+
expect(payload["skip"]).toBe(0);
|
|
1107
|
+
expect(payload["totalCount"]).toBe(250);
|
|
1108
|
+
expect(payload["hasMore"]).toBe(true);
|
|
1109
|
+
expect(projectSpy).toHaveBeenCalledTimes(1);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("the counts describe the WHOLE scope, never the page", async () => {
|
|
1113
|
+
/*
|
|
1114
|
+
* The single most important property of this design. A client that received
|
|
1115
|
+
* a page of 100 and a "2 not reachable" derived from it would print a
|
|
1116
|
+
* reassuring number under a partial list. The counts come from the service,
|
|
1117
|
+
* over the whole scope, and the page never touches them - which is why the
|
|
1118
|
+
* fixture's counts deliberately do not add up to the page size.
|
|
1119
|
+
*/
|
|
1120
|
+
await callGetRoute({ uri: PROJECT_ROUTE, query: { limit: "5" } });
|
|
1121
|
+
|
|
1122
|
+
const payload: Record<string, unknown> = jsonPayload();
|
|
1123
|
+
|
|
1124
|
+
expect(payload["users"]).toHaveLength(5);
|
|
1125
|
+
expect(payload["readyCount"]).toBe(3);
|
|
1126
|
+
expect(payload["partiallyReadyCount"]).toBe(1);
|
|
1127
|
+
expect(payload["notReachableCount"]).toBe(2);
|
|
1128
|
+
expect(payload["totalCount"]).toBe(250);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
test("skip walks the list, and hasMore goes false only on the last page", async () => {
|
|
1132
|
+
await callGetRoute({
|
|
1133
|
+
uri: PROJECT_ROUTE,
|
|
1134
|
+
query: { limit: "100", skip: "100" },
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
let payload: Record<string, unknown> = jsonPayload();
|
|
1138
|
+
expect(payload["users"]).toHaveLength(100);
|
|
1139
|
+
expect(payload["skip"]).toBe(100);
|
|
1140
|
+
expect(payload["hasMore"]).toBe(true);
|
|
1141
|
+
|
|
1142
|
+
await callGetRoute({
|
|
1143
|
+
uri: PROJECT_ROUTE,
|
|
1144
|
+
query: { limit: "100", skip: "200" },
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
payload = jsonPayload();
|
|
1148
|
+
expect(payload["users"]).toHaveLength(50);
|
|
1149
|
+
expect(payload["hasMore"]).toBe(false);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
test("a skip past the end is an empty page, not an error and not a wrap-around", async () => {
|
|
1153
|
+
await callGetRoute({
|
|
1154
|
+
uri: PROJECT_ROUTE,
|
|
1155
|
+
query: { limit: "100", skip: "1000" },
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
const payload: Record<string, unknown> = jsonPayload();
|
|
1159
|
+
expect(payload["users"]).toHaveLength(0);
|
|
1160
|
+
expect(payload["totalCount"]).toBe(250);
|
|
1161
|
+
expect(payload["hasMore"]).toBe(false);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
test("a limit above the ceiling is REFUSED, not silently clamped", async () => {
|
|
1165
|
+
/*
|
|
1166
|
+
* Clamping is the failure this endpoint is supposed to prevent, one level
|
|
1167
|
+
* up: a caller who asks for 5,000 and receives 500 with no indication of the
|
|
1168
|
+
* difference believes it has the whole list. Refusing names the ceiling and
|
|
1169
|
+
* forces the caller to page.
|
|
1170
|
+
*/
|
|
1171
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1172
|
+
uri: PROJECT_ROUTE,
|
|
1173
|
+
query: { limit: "5000" },
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
1177
|
+
expect((result.thrownToNext as BadDataException).message).toContain("500");
|
|
1178
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
test.each<[string]>([["0"], ["abc"], ["12abc"], ["-5"], ["1.5"]])(
|
|
1182
|
+
"a limit of %s is rejected rather than guessed at",
|
|
1183
|
+
async (limit: string) => {
|
|
1184
|
+
/*
|
|
1185
|
+
* parseInt("12abc") is 12 and parseInt("abc") is NaN. A page size taken
|
|
1186
|
+
* from either would be a page size nobody asked for, and NaN in
|
|
1187
|
+
* particular slices to an empty array - which renders as "this project has
|
|
1188
|
+
* no responders".
|
|
1189
|
+
*/
|
|
1190
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1191
|
+
uri: PROJECT_ROUTE,
|
|
1192
|
+
query: { limit: limit },
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
1196
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
1197
|
+
},
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
test("a malformed skip is rejected too", async () => {
|
|
1201
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1202
|
+
uri: PROJECT_ROUTE,
|
|
1203
|
+
query: { skip: "not-a-number" },
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
test("the policy route pages on the same contract", async () => {
|
|
1210
|
+
await callGetRoute({
|
|
1211
|
+
uri: POLICY_ROUTE,
|
|
1212
|
+
params: { policyId: policyId.toString() },
|
|
1213
|
+
query: { limit: "10", skip: "20" },
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
const payload: Record<string, unknown> = jsonPayload();
|
|
1217
|
+
|
|
1218
|
+
expect(payload["users"]).toHaveLength(10);
|
|
1219
|
+
expect(payload["limit"]).toBe(10);
|
|
1220
|
+
expect(payload["skip"]).toBe(20);
|
|
1221
|
+
expect(payload["totalCount"]).toBe(250);
|
|
1222
|
+
expect(payload["hasMore"]).toBe(true);
|
|
1223
|
+
expect(policySpy).toHaveBeenCalledTimes(1);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test("a truncated scope stays flagged on EVERY page, not just the first", async () => {
|
|
1227
|
+
/*
|
|
1228
|
+
* isTruncated says the scope itself is incomplete - responders may be
|
|
1229
|
+
* missing from it entirely - which is a different and worse thing than
|
|
1230
|
+
* hasMore. A client reading page three must still be told.
|
|
1231
|
+
*/
|
|
1232
|
+
const truncated: ReadinessSummary = stubSummaryWithUsers(250);
|
|
1233
|
+
truncated.isTruncated = true;
|
|
1234
|
+
truncated.isFallbackEnabled = false;
|
|
1235
|
+
projectSpy.mockResolvedValue(truncated as never);
|
|
1236
|
+
|
|
1237
|
+
await callGetRoute({
|
|
1238
|
+
uri: PROJECT_ROUTE,
|
|
1239
|
+
query: { limit: "10", skip: "200" },
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
const payload: Record<string, unknown> = jsonPayload();
|
|
1243
|
+
|
|
1244
|
+
expect(payload["isTruncated"]).toBe(true);
|
|
1245
|
+
expect(payload["isFallbackEnabled"]).toBe(false);
|
|
1246
|
+
expect(payload["skip"]).toBe(200);
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
test("paging is refused for a caller who is not a member, before any readiness work", async () => {
|
|
1250
|
+
propsSpy.mockResolvedValue({
|
|
1251
|
+
tenantId: projectId,
|
|
1252
|
+
userId: undefined,
|
|
1253
|
+
userTenantAccessPermission: undefined,
|
|
1254
|
+
} as never);
|
|
1255
|
+
|
|
1256
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1257
|
+
uri: PROJECT_ROUTE,
|
|
1258
|
+
query: { limit: "10" },
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
1262
|
+
expect(projectSpy).not.toHaveBeenCalled();
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
describe("GET /on-call-readiness/user/:userId", () => {
|
|
1267
|
+
let userSpy: jest.SpyInstance;
|
|
1268
|
+
|
|
1269
|
+
function stubUserReadiness(): UserReadiness {
|
|
1270
|
+
return stubSummary().users[0]!;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
beforeEach(() => {
|
|
1274
|
+
userSpy = jest
|
|
1275
|
+
.spyOn(OnCallReadinessService, "getReadinessForUser")
|
|
1276
|
+
.mockResolvedValue(stubUserReadiness() as never);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
test("returns the UserReadiness contract for a member of the caller's project", async () => {
|
|
1280
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1281
|
+
uri: USER_ROUTE,
|
|
1282
|
+
params: { userId: subjectUserId.toString() },
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
expect(result.nextCallCount).toBe(0);
|
|
1286
|
+
expect(userSpy).toHaveBeenCalledTimes(1);
|
|
1287
|
+
expect(userSpy.mock.calls[0]).toEqual([subjectUserId, projectId]);
|
|
1288
|
+
|
|
1289
|
+
const payload: Record<string, unknown> = jsonPayload();
|
|
1290
|
+
expect(Object.keys(payload).sort()).toEqual([
|
|
1291
|
+
"coverage",
|
|
1292
|
+
"methods",
|
|
1293
|
+
"reachedVia",
|
|
1294
|
+
"reasons",
|
|
1295
|
+
"status",
|
|
1296
|
+
"teams",
|
|
1297
|
+
"userEmail",
|
|
1298
|
+
"userId",
|
|
1299
|
+
"userName",
|
|
1300
|
+
"userProfilePictureId",
|
|
1301
|
+
]);
|
|
1302
|
+
expect(payload["userId"]).toBe(subjectUserId.toString());
|
|
1303
|
+
// Not wrapped in a summary - this route answers about one person.
|
|
1304
|
+
expect(payload).not.toHaveProperty("users");
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
test("the membership check names the path's user, in the caller's project, as root", async () => {
|
|
1308
|
+
await callGetRoute({
|
|
1309
|
+
uri: USER_ROUTE,
|
|
1310
|
+
params: { userId: subjectUserId.toString() },
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
const call: CapturedFindBy = firstCall(teamMemberFindBy);
|
|
1314
|
+
expect((call.query["userId"] as ObjectID).toString()).toBe(
|
|
1315
|
+
subjectUserId.toString(),
|
|
1316
|
+
);
|
|
1317
|
+
expect((call.query["projectId"] as ObjectID).toString()).toBe(
|
|
1318
|
+
projectId.toString(),
|
|
1319
|
+
);
|
|
1320
|
+
// Existence check only.
|
|
1321
|
+
expect(call.limit).toBe(1);
|
|
1322
|
+
expect(call.skip).toBe(0);
|
|
1323
|
+
expect(call.props?.isRoot).toBe(true);
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
test("a user outside the caller's project is refused before their name is read", async () => {
|
|
1327
|
+
/*
|
|
1328
|
+
* User is a GLOBAL model - nothing about it is project-scoped - so without
|
|
1329
|
+
* this check the route hands back a stranger's display name and login email
|
|
1330
|
+
* to anyone holding any project's credentials. Every other query underneath
|
|
1331
|
+
* is project-scoped and would come back empty, which is precisely what makes
|
|
1332
|
+
* this the leak that would go unnoticed.
|
|
1333
|
+
*/
|
|
1334
|
+
teamMemberFindBy.mockResolvedValue([] as never);
|
|
1335
|
+
|
|
1336
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1337
|
+
uri: USER_ROUTE,
|
|
1338
|
+
params: { userId: subjectUserId.toString() },
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
1342
|
+
expect((result.thrownToNext as NotAuthorizedException).message).toBe(
|
|
1343
|
+
REFUSAL,
|
|
1344
|
+
);
|
|
1345
|
+
expect(userSpy).not.toHaveBeenCalled();
|
|
1346
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
test("a user id that does not exist is refused with the same words as a foreign user", async () => {
|
|
1350
|
+
// Both cases produce no membership row, and must be indistinguishable.
|
|
1351
|
+
teamMemberFindBy.mockResolvedValue([] as never);
|
|
1352
|
+
|
|
1353
|
+
const unknown: RouteCallResult = await callGetRoute({
|
|
1354
|
+
uri: USER_ROUTE,
|
|
1355
|
+
params: { userId: ObjectID.generate().toString() },
|
|
1356
|
+
});
|
|
1357
|
+
const foreign: RouteCallResult = await callGetRoute({
|
|
1358
|
+
uri: USER_ROUTE,
|
|
1359
|
+
params: { userId: subjectUserId.toString() },
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
expect((unknown.thrownToNext as NotAuthorizedException).message).toBe(
|
|
1363
|
+
(foreign.thrownToNext as NotAuthorizedException).message,
|
|
1364
|
+
);
|
|
1365
|
+
expect((unknown.thrownToNext as NotAuthorizedException).message).toBe(
|
|
1366
|
+
REFUSAL,
|
|
1367
|
+
);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test("a malformed user id is rejected before the membership query", async () => {
|
|
1371
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1372
|
+
uri: USER_ROUTE,
|
|
1373
|
+
params: { userId: "12345" },
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
1377
|
+
expect(teamMemberFindBy).not.toHaveBeenCalled();
|
|
1378
|
+
expect(userSpy).not.toHaveBeenCalled();
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
test("refuses an unauthenticated caller before the membership query", async () => {
|
|
1382
|
+
propsSpy.mockResolvedValue({} as never);
|
|
1383
|
+
|
|
1384
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1385
|
+
uri: USER_ROUTE,
|
|
1386
|
+
params: { userId: subjectUserId.toString() },
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
1390
|
+
expect(teamMemberFindBy).not.toHaveBeenCalled();
|
|
1391
|
+
expect(userSpy).not.toHaveBeenCalled();
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
test("refuses a public caller that merely supplies a tenantid header", async () => {
|
|
1395
|
+
propsSpy.mockResolvedValue({
|
|
1396
|
+
tenantId: projectId,
|
|
1397
|
+
userId: undefined,
|
|
1398
|
+
userTenantAccessPermission: undefined,
|
|
1399
|
+
} as never);
|
|
1400
|
+
|
|
1401
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1402
|
+
uri: USER_ROUTE,
|
|
1403
|
+
params: { userId: subjectUserId.toString() },
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
1407
|
+
expect(teamMemberFindBy).not.toHaveBeenCalled();
|
|
1408
|
+
expect(userSpy).not.toHaveBeenCalled();
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
/*
|
|
1413
|
+
* --------------------------------------------------------------------------- *
|
|
1414
|
+
* Masking, end to end.
|
|
1415
|
+
*
|
|
1416
|
+
* Nothing is stubbed between the raw rows below and the JSON the route returns
|
|
1417
|
+
* except the database reads themselves, because the claim under test is exactly
|
|
1418
|
+
* that: a raw identifier written into the database cannot come back out of this
|
|
1419
|
+
* endpoint. A masking test that stubbed OnCallReadinessService would assert only
|
|
1420
|
+
* that the API copies a field it was handed.
|
|
1421
|
+
* ---------------------------------------------------------------------------
|
|
1422
|
+
*/
|
|
1423
|
+
|
|
1424
|
+
describe("GET /on-call-readiness/user/:userId - identifier masking", () => {
|
|
1425
|
+
/*
|
|
1426
|
+
* The login email and the notification email are deliberately DIFFERENT
|
|
1427
|
+
* addresses here. They are different values with different exposure - the
|
|
1428
|
+
* login email is admin-readable everywhere a user is listed, the notification
|
|
1429
|
+
* address is not - and keeping them distinct is what lets the leak test below
|
|
1430
|
+
* search the whole response body for the notification address without
|
|
1431
|
+
* tripping over the login one, which is supposed to be there in full.
|
|
1432
|
+
*/
|
|
1433
|
+
const LOGIN_EMAIL: string = "jane@corp.example.com";
|
|
1434
|
+
const RAW_EMAIL: string = "jane.doe@personal.example.com";
|
|
1435
|
+
const RAW_SMS: string = "+14155554821";
|
|
1436
|
+
const RAW_CALL: string = "+442071234567";
|
|
1437
|
+
const RAW_WHATSAPP: string = "+14155559999";
|
|
1438
|
+
const RAW_HANDLE: string = "@jamesbond";
|
|
1439
|
+
const RAW_DEVICE: string = "Jane's iPhone 15 Pro";
|
|
1440
|
+
const RAW_WEBHOOK_NAME: string = "Payments Slack Hook";
|
|
1441
|
+
const RAW_WEBHOOK_URL: string =
|
|
1442
|
+
"https://hooks.slack.com/services/T000/B000/XXXXsecretXXXX";
|
|
1443
|
+
|
|
1444
|
+
/*
|
|
1445
|
+
* One fixed id per channel, and fixed rather than generated so that a failure
|
|
1446
|
+
* prints a value a reader can match against the fixture that produced it.
|
|
1447
|
+
*
|
|
1448
|
+
* These are the ids UserNotificationRule's seven foreign keys reference -
|
|
1449
|
+
* userEmailId points at a UserEmail row, userSmsId at a UserSMS row - and they
|
|
1450
|
+
* are the reason `methodId` exists at all. An administrator building a rule
|
|
1451
|
+
* for somebody else may not READ any of these rows; what they get instead is
|
|
1452
|
+
* this id beside a mask, which is enough to point a rule at a method and not
|
|
1453
|
+
* enough to page anyone. So the id has to be on the wire, and it has to be the
|
|
1454
|
+
* id of the METHOD row: a rule carrying a user id or a rule id in userSmsId
|
|
1455
|
+
* references nothing, and a rule that references nothing pages nobody.
|
|
1456
|
+
*/
|
|
1457
|
+
const PUSH_METHOD_ID: ObjectID = new ObjectID(
|
|
1458
|
+
"1a000000-0000-4000-8000-000000000001",
|
|
1459
|
+
);
|
|
1460
|
+
const EMAIL_METHOD_ID: ObjectID = new ObjectID(
|
|
1461
|
+
"1a000000-0000-4000-8000-000000000002",
|
|
1462
|
+
);
|
|
1463
|
+
const SMS_METHOD_ID: ObjectID = new ObjectID(
|
|
1464
|
+
"1a000000-0000-4000-8000-000000000003",
|
|
1465
|
+
);
|
|
1466
|
+
const CALL_METHOD_ID: ObjectID = new ObjectID(
|
|
1467
|
+
"1a000000-0000-4000-8000-000000000004",
|
|
1468
|
+
);
|
|
1469
|
+
const WHATSAPP_METHOD_ID: ObjectID = new ObjectID(
|
|
1470
|
+
"1a000000-0000-4000-8000-000000000005",
|
|
1471
|
+
);
|
|
1472
|
+
const TELEGRAM_METHOD_ID: ObjectID = new ObjectID(
|
|
1473
|
+
"1a000000-0000-4000-8000-000000000006",
|
|
1474
|
+
);
|
|
1475
|
+
const WEBHOOK_METHOD_ID: ObjectID = new ObjectID(
|
|
1476
|
+
"1a000000-0000-4000-8000-000000000007",
|
|
1477
|
+
);
|
|
1478
|
+
|
|
1479
|
+
/*
|
|
1480
|
+
* The expected id per channel, in one place, so the sweep below is a sweep
|
|
1481
|
+
* over all seven rather than seven assertions that can each be forgotten
|
|
1482
|
+
* individually. Keyed by the wire's `methodType` string.
|
|
1483
|
+
*/
|
|
1484
|
+
const METHOD_ID_BY_TYPE: Dictionary<string> = {
|
|
1485
|
+
[ReadinessMethodType.Push]: PUSH_METHOD_ID.toString(),
|
|
1486
|
+
[ReadinessMethodType.Email]: EMAIL_METHOD_ID.toString(),
|
|
1487
|
+
[ReadinessMethodType.SMS]: SMS_METHOD_ID.toString(),
|
|
1488
|
+
[ReadinessMethodType.Call]: CALL_METHOD_ID.toString(),
|
|
1489
|
+
[ReadinessMethodType.WhatsApp]: WHATSAPP_METHOD_ID.toString(),
|
|
1490
|
+
[ReadinessMethodType.Telegram]: TELEGRAM_METHOD_ID.toString(),
|
|
1491
|
+
[ReadinessMethodType.Webhook]: WEBHOOK_METHOD_ID.toString(),
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
/*
|
|
1495
|
+
* REAL model instances, not object literals shaped like rows.
|
|
1496
|
+
*
|
|
1497
|
+
* `_id` is a string column and `id` is a getter over it that hands back an
|
|
1498
|
+
* ObjectID; a plain object has the first and not the second, and the service
|
|
1499
|
+
* reads the second. Stubbing findBy with literals therefore produces rows the
|
|
1500
|
+
* service quietly DROPS for having no id of their own - which would empty the
|
|
1501
|
+
* `methods` array and turn every leak assertion in this block green for the
|
|
1502
|
+
* one reason that proves nothing. Constructing the models is what keeps these
|
|
1503
|
+
* tests honest about the path they claim to exercise.
|
|
1504
|
+
*/
|
|
1505
|
+
beforeEach(() => {
|
|
1506
|
+
userFindBy.mockResolvedValue([
|
|
1507
|
+
{
|
|
1508
|
+
id: subjectUserId,
|
|
1509
|
+
name: "Jane Doe",
|
|
1510
|
+
email: LOGIN_EMAIL,
|
|
1511
|
+
},
|
|
1512
|
+
] as never);
|
|
1513
|
+
|
|
1514
|
+
const email: UserEmail = new UserEmail();
|
|
1515
|
+
email.id = EMAIL_METHOD_ID;
|
|
1516
|
+
email.userId = subjectUserId;
|
|
1517
|
+
email.email = new Email(RAW_EMAIL);
|
|
1518
|
+
email.isVerified = true;
|
|
1519
|
+
userEmailFindBy.mockResolvedValue([email] as never);
|
|
1520
|
+
|
|
1521
|
+
const sms: UserSMS = new UserSMS();
|
|
1522
|
+
sms.id = SMS_METHOD_ID;
|
|
1523
|
+
sms.userId = subjectUserId;
|
|
1524
|
+
sms.phone = new Phone(RAW_SMS);
|
|
1525
|
+
sms.isVerified = true;
|
|
1526
|
+
userSmsFindBy.mockResolvedValue([sms] as never);
|
|
1527
|
+
|
|
1528
|
+
const call: UserCall = new UserCall();
|
|
1529
|
+
call.id = CALL_METHOD_ID;
|
|
1530
|
+
call.userId = subjectUserId;
|
|
1531
|
+
call.phone = new Phone(RAW_CALL);
|
|
1532
|
+
call.isVerified = true;
|
|
1533
|
+
userCallFindBy.mockResolvedValue([call] as never);
|
|
1534
|
+
|
|
1535
|
+
const whatsApp: UserWhatsApp = new UserWhatsApp();
|
|
1536
|
+
whatsApp.id = WHATSAPP_METHOD_ID;
|
|
1537
|
+
whatsApp.userId = subjectUserId;
|
|
1538
|
+
whatsApp.phone = new Phone(RAW_WHATSAPP);
|
|
1539
|
+
whatsApp.isVerified = true;
|
|
1540
|
+
userWhatsAppFindBy.mockResolvedValue([whatsApp] as never);
|
|
1541
|
+
|
|
1542
|
+
const telegram: UserTelegram = new UserTelegram();
|
|
1543
|
+
telegram.id = TELEGRAM_METHOD_ID;
|
|
1544
|
+
telegram.userId = subjectUserId;
|
|
1545
|
+
telegram.telegramUserHandle = RAW_HANDLE;
|
|
1546
|
+
// On the row, and never selected: the chat id is the addressable target.
|
|
1547
|
+
telegram.telegramChatId = "998877";
|
|
1548
|
+
telegram.isVerified = true;
|
|
1549
|
+
userTelegramFindBy.mockResolvedValue([telegram] as never);
|
|
1550
|
+
|
|
1551
|
+
const push: UserPush = new UserPush();
|
|
1552
|
+
push.id = PUSH_METHOD_ID;
|
|
1553
|
+
push.userId = subjectUserId;
|
|
1554
|
+
push.deviceName = RAW_DEVICE;
|
|
1555
|
+
push.isVerified = true;
|
|
1556
|
+
userPushFindBy.mockResolvedValue([push] as never);
|
|
1557
|
+
|
|
1558
|
+
const webhook: UserWebhook = new UserWebhook();
|
|
1559
|
+
webhook.id = WEBHOOK_METHOD_ID;
|
|
1560
|
+
webhook.userId = subjectUserId;
|
|
1561
|
+
webhook.name = RAW_WEBHOOK_NAME;
|
|
1562
|
+
// Present on the row, and must never be selected or emitted.
|
|
1563
|
+
webhook.webhookUrl = RAW_WEBHOOK_URL;
|
|
1564
|
+
userWebhookFindBy.mockResolvedValue([webhook] as never);
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
async function readUser(): Promise<Record<string, unknown>> {
|
|
1568
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1569
|
+
uri: USER_ROUTE,
|
|
1570
|
+
params: { userId: subjectUserId.toString() },
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
expect(result.nextCallCount).toBe(0);
|
|
1574
|
+
|
|
1575
|
+
return jsonPayload();
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function methodsOf(
|
|
1579
|
+
payload: Record<string, unknown>,
|
|
1580
|
+
): Array<Record<string, unknown>> {
|
|
1581
|
+
return payload["methods"] as Array<Record<string, unknown>>;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
test("every method identifier arrives masked, one per channel, in fallback order", async () => {
|
|
1585
|
+
const payload: Record<string, unknown> = await readUser();
|
|
1586
|
+
|
|
1587
|
+
expect(payload["methods"]).toEqual([
|
|
1588
|
+
{
|
|
1589
|
+
methodId: PUSH_METHOD_ID.toString(),
|
|
1590
|
+
methodType: ReadinessMethodType.Push,
|
|
1591
|
+
maskedIdentifier: `Ja${IDENTIFIER_MASK}`,
|
|
1592
|
+
isVerified: true,
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
methodId: EMAIL_METHOD_ID.toString(),
|
|
1596
|
+
methodType: ReadinessMethodType.Email,
|
|
1597
|
+
maskedIdentifier: `j${IDENTIFIER_MASK}@personal.example.com`,
|
|
1598
|
+
isVerified: true,
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
methodId: SMS_METHOD_ID.toString(),
|
|
1602
|
+
methodType: ReadinessMethodType.SMS,
|
|
1603
|
+
maskedIdentifier: `+1 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`,
|
|
1604
|
+
isVerified: true,
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
methodId: CALL_METHOD_ID.toString(),
|
|
1608
|
+
methodType: ReadinessMethodType.Call,
|
|
1609
|
+
maskedIdentifier: `+44 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4567`,
|
|
1610
|
+
isVerified: true,
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
methodId: WHATSAPP_METHOD_ID.toString(),
|
|
1614
|
+
methodType: ReadinessMethodType.WhatsApp,
|
|
1615
|
+
maskedIdentifier: `+1 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 9999`,
|
|
1616
|
+
isVerified: true,
|
|
1617
|
+
},
|
|
1618
|
+
{
|
|
1619
|
+
methodId: TELEGRAM_METHOD_ID.toString(),
|
|
1620
|
+
methodType: ReadinessMethodType.Telegram,
|
|
1621
|
+
maskedIdentifier: `@ja${IDENTIFIER_MASK}`,
|
|
1622
|
+
isVerified: true,
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
/*
|
|
1626
|
+
* UserWebhook has no verification concept at all - its presence is the
|
|
1627
|
+
* whole test, which is how the runtime fallback treats it too.
|
|
1628
|
+
*/
|
|
1629
|
+
methodId: WEBHOOK_METHOD_ID.toString(),
|
|
1630
|
+
methodType: ReadinessMethodType.Webhook,
|
|
1631
|
+
maskedIdentifier: `Pa${IDENTIFIER_MASK}`,
|
|
1632
|
+
isVerified: true,
|
|
1633
|
+
},
|
|
1634
|
+
]);
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
test("all seven channels reach the wire carrying the id of their OWN row", async () => {
|
|
1638
|
+
/*
|
|
1639
|
+
* The dropdown's entire premise, swept across every channel rather than
|
|
1640
|
+
* spot-checked on one, because the seven are seven separate code paths that
|
|
1641
|
+
* each build their own row and each have their own opportunity to hand over
|
|
1642
|
+
* somebody else's id - or none at all.
|
|
1643
|
+
*
|
|
1644
|
+
* Asserted through the ROUTE rather than against the service, because the
|
|
1645
|
+
* service is not where this can be lost any more: the serialiser copies the
|
|
1646
|
+
* fields it names, so a `methodId` the service computes and the API does not
|
|
1647
|
+
* copy is invisible everywhere except here, and it fails as an admin looking
|
|
1648
|
+
* at a rule form with no options in it.
|
|
1649
|
+
*/
|
|
1650
|
+
const payload: Record<string, unknown> = await readUser();
|
|
1651
|
+
const methods: Array<Record<string, unknown>> = methodsOf(payload);
|
|
1652
|
+
|
|
1653
|
+
expect(methods).toHaveLength(7);
|
|
1654
|
+
|
|
1655
|
+
for (const method of methods) {
|
|
1656
|
+
const methodType: string = method["methodType"] as string;
|
|
1657
|
+
const expected: string | undefined = METHOD_ID_BY_TYPE[methodType];
|
|
1658
|
+
|
|
1659
|
+
/*
|
|
1660
|
+
* Guards the sweep: an unrecognised channel would otherwise compare
|
|
1661
|
+
* undefined against undefined and pass. An eighth channel is a contract
|
|
1662
|
+
* change and must be added to the table above deliberately.
|
|
1663
|
+
*/
|
|
1664
|
+
expect(expected).toBeDefined();
|
|
1665
|
+
expect(`${methodType}: ${String(method["methodId"])}`).toBe(
|
|
1666
|
+
`${methodType}: ${String(expected)}`,
|
|
1667
|
+
);
|
|
1668
|
+
expect(typeof method["methodId"]).toBe("string");
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
test("no methodId is the responder's user id, which references no method at all", async () => {
|
|
1673
|
+
/*
|
|
1674
|
+
* `row.userId` and `row.id` are both ObjectIDs on the same row, and the
|
|
1675
|
+
* mistake of emitting the first where the second belongs type-checks,
|
|
1676
|
+
* renders identically and is only discovered when a rule saved against it
|
|
1677
|
+
* turns out to reference a User where a UserSMS was expected. That rule
|
|
1678
|
+
* dereferences to nothing at page time, which is the failure this whole
|
|
1679
|
+
* feature exists to make visible - so it must not be the failure the feature
|
|
1680
|
+
* introduces.
|
|
1681
|
+
*/
|
|
1682
|
+
const payload: Record<string, unknown> = await readUser();
|
|
1683
|
+
const methods: Array<Record<string, unknown>> = methodsOf(payload);
|
|
1684
|
+
|
|
1685
|
+
expect(methods).toHaveLength(7);
|
|
1686
|
+
|
|
1687
|
+
for (const method of methods) {
|
|
1688
|
+
expect(method["methodId"]).not.toBe(subjectUserId.toString());
|
|
1689
|
+
// Nor the policy or the project the responder was reached through.
|
|
1690
|
+
expect(method["methodId"]).not.toBe(policyId.toString());
|
|
1691
|
+
expect(method["methodId"]).not.toBe(projectId.toString());
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// And all seven are distinct - one id copied across channels is the same bug.
|
|
1695
|
+
const ids: Array<unknown> = methods.map(
|
|
1696
|
+
(method: Record<string, unknown>): unknown => {
|
|
1697
|
+
return method["methodId"];
|
|
1698
|
+
},
|
|
1699
|
+
);
|
|
1700
|
+
expect(new Set(ids).size).toBe(7);
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
test("a method carries its id, its type, its mask and its verification - and nothing else", async () => {
|
|
1704
|
+
/*
|
|
1705
|
+
* The containment assertion. Every field on this object ships to every
|
|
1706
|
+
* administrator of the project, about a row that administrator is not
|
|
1707
|
+
* allowed to read, so the key set is pinned exhaustively on all seven rather
|
|
1708
|
+
* than left to whatever the serialiser happens to copy. A `phone`, a
|
|
1709
|
+
* `webhookUrl` or a `telegramChatId` appearing here is not a formatting
|
|
1710
|
+
* change; it is the exposure this design was built to avoid.
|
|
1711
|
+
*/
|
|
1712
|
+
const payload: Record<string, unknown> = await readUser();
|
|
1713
|
+
|
|
1714
|
+
for (const method of methodsOf(payload)) {
|
|
1715
|
+
expect(Object.keys(method).sort()).toEqual([
|
|
1716
|
+
"isVerified",
|
|
1717
|
+
"maskedIdentifier",
|
|
1718
|
+
"methodId",
|
|
1719
|
+
"methodType",
|
|
1720
|
+
]);
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
test("not one raw identifier appears anywhere in the response body", async () => {
|
|
1725
|
+
const payload: Record<string, unknown> = await readUser();
|
|
1726
|
+
const body: string = JSON.stringify(payload);
|
|
1727
|
+
|
|
1728
|
+
/*
|
|
1729
|
+
* Guards the guard, and it is not a formality here: the service drops any
|
|
1730
|
+
* method row it cannot find an id on, so a fixture regression that emptied
|
|
1731
|
+
* `methods` would satisfy every "not present" assertion below for the one
|
|
1732
|
+
* reason that proves nothing at all. The seven masks have to be in the body
|
|
1733
|
+
* before their absence of raw values means anything.
|
|
1734
|
+
*/
|
|
1735
|
+
expect(methodsOf(payload)).toHaveLength(7);
|
|
1736
|
+
expect(body).toContain(IDENTIFIER_MASK);
|
|
1737
|
+
|
|
1738
|
+
/*
|
|
1739
|
+
* Serialised and searched wholesale rather than field by field, because the
|
|
1740
|
+
* failure this guards against is a raw value appearing somewhere nobody
|
|
1741
|
+
* thought to assert on - a reason sentence, a new field, a nested object.
|
|
1742
|
+
*/
|
|
1743
|
+
for (const raw of [
|
|
1744
|
+
RAW_EMAIL,
|
|
1745
|
+
RAW_SMS,
|
|
1746
|
+
RAW_CALL,
|
|
1747
|
+
RAW_WHATSAPP,
|
|
1748
|
+
RAW_HANDLE,
|
|
1749
|
+
RAW_DEVICE,
|
|
1750
|
+
RAW_WEBHOOK_NAME,
|
|
1751
|
+
RAW_WEBHOOK_URL,
|
|
1752
|
+
// The notification address's local part on its own, and the hook's host.
|
|
1753
|
+
"jane.doe@",
|
|
1754
|
+
"hooks.slack.com",
|
|
1755
|
+
// The telegram chat id - the addressable target, never the label.
|
|
1756
|
+
"998877",
|
|
1757
|
+
/*
|
|
1758
|
+
* The unmasked tails of the four phone-shaped identifiers. maskIdentifier
|
|
1759
|
+
* keeps the last four digits on purpose, so the numbers themselves are
|
|
1760
|
+
* spelled out here without their separators as well: a serialiser that
|
|
1761
|
+
* emitted E.164 alongside the mask would still be caught even though the
|
|
1762
|
+
* mask is present and correct beside it.
|
|
1763
|
+
*/
|
|
1764
|
+
"4155554821",
|
|
1765
|
+
"442071234567",
|
|
1766
|
+
"4155559999",
|
|
1767
|
+
]) {
|
|
1768
|
+
expect(body).not.toContain(raw);
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
test("the id on the wire is the METHOD row's, told apart from the RULE that points at it", async () => {
|
|
1773
|
+
/*
|
|
1774
|
+
* The two ids a rule form juggles are the rule's own id and the method id it
|
|
1775
|
+
* writes into userSmsId, and they are indistinguishable by shape. This
|
|
1776
|
+
* stages a rule row whose id is a value nothing else in the fixture uses,
|
|
1777
|
+
* then asserts it does not turn up in `methods` - because a dropdown offering
|
|
1778
|
+
* rule ids would write userSmsId values that dereference to a
|
|
1779
|
+
* UserNotificationRule, which pages nobody and reads, on the readiness table
|
|
1780
|
+
* that would then describe it, as a method that exists.
|
|
1781
|
+
*/
|
|
1782
|
+
const ruleId: ObjectID = new ObjectID(
|
|
1783
|
+
"1b000000-0000-4000-8000-0000000000ff",
|
|
1784
|
+
);
|
|
1785
|
+
|
|
1786
|
+
notificationRuleFindBy.mockResolvedValue([
|
|
1787
|
+
{
|
|
1788
|
+
_id: ruleId.toString(),
|
|
1789
|
+
userId: subjectUserId,
|
|
1790
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
1791
|
+
userSmsId: SMS_METHOD_ID,
|
|
1792
|
+
},
|
|
1793
|
+
] as never);
|
|
1794
|
+
|
|
1795
|
+
const payload: Record<string, unknown> = await readUser();
|
|
1796
|
+
const methods: Array<Record<string, unknown>> = methodsOf(payload);
|
|
1797
|
+
|
|
1798
|
+
expect(methods).toHaveLength(7);
|
|
1799
|
+
|
|
1800
|
+
for (const method of methods) {
|
|
1801
|
+
expect(method["methodId"]).not.toBe(ruleId.toString());
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/*
|
|
1805
|
+
* And the SMS entry still carries the id that rule references, which is the
|
|
1806
|
+
* positive half: the value an admin picks and the value already stored on an
|
|
1807
|
+
* existing rule have to be the same value, or the form cannot show the
|
|
1808
|
+
* current selection.
|
|
1809
|
+
*/
|
|
1810
|
+
const smsMethod: Record<string, unknown> | undefined = methods.find(
|
|
1811
|
+
(method: Record<string, unknown>): boolean => {
|
|
1812
|
+
return method["methodType"] === ReadinessMethodType.SMS;
|
|
1813
|
+
},
|
|
1814
|
+
);
|
|
1815
|
+
expect(smsMethod?.["methodId"]).toBe(SMS_METHOD_ID.toString());
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
test("the login email is NOT masked, but the notification email still is", async () => {
|
|
1819
|
+
/*
|
|
1820
|
+
* Deliberate, and worth pinning precisely because it looks like a leak: the
|
|
1821
|
+
* login email is already admin-readable everywhere a user is listed, and
|
|
1822
|
+
* masking it here would make the readiness table the one surface where two
|
|
1823
|
+
* people called "J. Doe" cannot be told apart. The NOTIFICATION email is a
|
|
1824
|
+
* different value with different exposure and is masked - the two are
|
|
1825
|
+
* separate fields with separate rules, not one value rendered twice.
|
|
1826
|
+
*/
|
|
1827
|
+
const payload: Record<string, unknown> = await readUser();
|
|
1828
|
+
|
|
1829
|
+
expect(payload["userEmail"]).toBe(LOGIN_EMAIL);
|
|
1830
|
+
|
|
1831
|
+
const methods: Array<Record<string, unknown>> = payload["methods"] as Array<
|
|
1832
|
+
Record<string, unknown>
|
|
1833
|
+
>;
|
|
1834
|
+
const email: Record<string, unknown> | undefined = methods.find(
|
|
1835
|
+
(method: Record<string, unknown>): boolean => {
|
|
1836
|
+
return method["methodType"] === ReadinessMethodType.Email;
|
|
1837
|
+
},
|
|
1838
|
+
);
|
|
1839
|
+
|
|
1840
|
+
expect(email?.["maskedIdentifier"]).toBe(
|
|
1841
|
+
`j${IDENTIFIER_MASK}@personal.example.com`,
|
|
1842
|
+
);
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
test("the webhook read never selects the bearer credential", async () => {
|
|
1846
|
+
/*
|
|
1847
|
+
* UserWebhook.webhookUrl is a bearer credential - anyone holding a
|
|
1848
|
+
* Slack/Discord/Teams hook URL can post as that integration. The strongest
|
|
1849
|
+
* available guarantee is that the column is never even SELECTed, so no
|
|
1850
|
+
* later serialiser change can start emitting a value the row does not carry.
|
|
1851
|
+
*/
|
|
1852
|
+
await readUser();
|
|
1853
|
+
|
|
1854
|
+
const call: CapturedFindBy = firstCall(userWebhookFindBy);
|
|
1855
|
+
|
|
1856
|
+
expect(Object.keys(call.select || {}).sort()).toEqual([
|
|
1857
|
+
"_id",
|
|
1858
|
+
"name",
|
|
1859
|
+
"userId",
|
|
1860
|
+
]);
|
|
1861
|
+
expect(call.select?.["webhookUrl"]).toBeUndefined();
|
|
1862
|
+
expect(call.select?.["url"]).toBeUndefined();
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
test("the telegram read takes the handle, never the addressable chat id", async () => {
|
|
1866
|
+
await readUser();
|
|
1867
|
+
|
|
1868
|
+
const call: CapturedFindBy = firstCall(userTelegramFindBy);
|
|
1869
|
+
|
|
1870
|
+
expect(call.select?.["telegramUserHandle"]).toBe(true);
|
|
1871
|
+
expect(call.select?.["telegramChatId"]).toBeUndefined();
|
|
1872
|
+
});
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
/*
|
|
1876
|
+
* --------------------------------------------------------------------------- *
|
|
1877
|
+
* The TeamComplianceService rebuild, exercised through its own route with the
|
|
1878
|
+
* REAL readiness service underneath.
|
|
1879
|
+
*
|
|
1880
|
+
* Teams > View > Compliance renders this payload field for field - including
|
|
1881
|
+
* the reason strings, which it prints as prose rather than mapping through any
|
|
1882
|
+
* lookup - so the shape is a hard contract. What changed underneath is where
|
|
1883
|
+
* the two "does this user have on-call rules?" answers come from, and the four
|
|
1884
|
+
* defects that lived in the old answer are re-tested here across the real seam
|
|
1885
|
+
* rather than against a stubbed readiness service.
|
|
1886
|
+
* ---------------------------------------------------------------------------
|
|
1887
|
+
*/
|
|
1888
|
+
|
|
1889
|
+
interface StubRule {
|
|
1890
|
+
_id: string;
|
|
1891
|
+
userId: ObjectID;
|
|
1892
|
+
ruleType: NotificationRuleType;
|
|
1893
|
+
incidentSeverityId?: ObjectID | undefined;
|
|
1894
|
+
alertSeverityId?: ObjectID | undefined;
|
|
1895
|
+
isOptOut?: boolean | undefined;
|
|
1896
|
+
userCallId?: ObjectID | undefined;
|
|
1897
|
+
userSmsId?: ObjectID | undefined;
|
|
1898
|
+
userEmailId?: ObjectID | undefined;
|
|
1899
|
+
userPushId?: ObjectID | undefined;
|
|
1900
|
+
userTelegramId?: ObjectID | undefined;
|
|
1901
|
+
userWhatsAppId?: ObjectID | undefined;
|
|
1902
|
+
userWebhookId?: ObjectID | undefined;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
describe("GET /team/compliance-status/:teamId - the rebuilt service", () => {
|
|
1906
|
+
let ada: StubUser;
|
|
1907
|
+
let grace: StubUser;
|
|
1908
|
+
let critical: StubSeverity;
|
|
1909
|
+
let major: StubSeverity;
|
|
1910
|
+
let page: StubSeverity;
|
|
1911
|
+
|
|
1912
|
+
function stage(data: {
|
|
1913
|
+
settings: Array<{ ruleType: ComplianceRuleType; enabled: boolean }>;
|
|
1914
|
+
members: Array<StubUser>;
|
|
1915
|
+
rules: Array<StubRule>;
|
|
1916
|
+
incidentSeverities: Array<StubSeverity>;
|
|
1917
|
+
alertSeverities: Array<StubSeverity>;
|
|
1918
|
+
}): void {
|
|
1919
|
+
complianceSettingFindBy.mockResolvedValue(data.settings as never);
|
|
1920
|
+
|
|
1921
|
+
teamMemberFindBy.mockResolvedValue(
|
|
1922
|
+
data.members.map((member: StubUser): Record<string, unknown> => {
|
|
1923
|
+
return { _id: `tm-${member.id.toString()}`, userId: member.id };
|
|
1924
|
+
}) as never,
|
|
1925
|
+
);
|
|
1926
|
+
|
|
1927
|
+
userFindBy.mockResolvedValue(data.members as never);
|
|
1928
|
+
|
|
1929
|
+
/*
|
|
1930
|
+
* The members are attached DIRECTLY to an escalation rule, which is the
|
|
1931
|
+
* arrangement that lets the project-scope readiness summary answer for all
|
|
1932
|
+
* of them in one pass. It is also the arrangement TeamComplianceService is
|
|
1933
|
+
* designed around: a team on a compliance page exists because its members
|
|
1934
|
+
* are on call.
|
|
1935
|
+
*/
|
|
1936
|
+
escalationUserFindBy.mockResolvedValue(
|
|
1937
|
+
data.members.map((member: StubUser): Record<string, unknown> => {
|
|
1938
|
+
return { _id: `er-${member.id.toString()}`, userId: member.id };
|
|
1939
|
+
}) as never,
|
|
1940
|
+
);
|
|
1941
|
+
|
|
1942
|
+
notificationRuleFindBy.mockResolvedValue(data.rules as never);
|
|
1943
|
+
incidentSeverityFindBy.mockResolvedValue(data.incidentSeverities as never);
|
|
1944
|
+
alertSeverityFindBy.mockResolvedValue(data.alertSeverities as never);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
async function readCompliance(): Promise<Record<string, unknown>> {
|
|
1948
|
+
const result: RouteCallResult = await callGetRoute({
|
|
1949
|
+
uri: COMPLIANCE_ROUTE,
|
|
1950
|
+
params: { teamId: teamId.toString() },
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
expect(result.nextCallCount).toBe(0);
|
|
1954
|
+
|
|
1955
|
+
return jsonPayload();
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function statusesOf(
|
|
1959
|
+
payload: Record<string, unknown>,
|
|
1960
|
+
): Array<Record<string, unknown>> {
|
|
1961
|
+
return payload["userComplianceStatuses"] as Array<Record<string, unknown>>;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
beforeEach(() => {
|
|
1965
|
+
ada = { id: ObjectID.generate(), name: "Ada", email: "ada@example.com" };
|
|
1966
|
+
grace = {
|
|
1967
|
+
id: ObjectID.generate(),
|
|
1968
|
+
name: "Grace",
|
|
1969
|
+
email: "grace@example.com",
|
|
1970
|
+
};
|
|
1971
|
+
critical = { id: ObjectID.generate(), name: "Critical" };
|
|
1972
|
+
major = { id: ObjectID.generate(), name: "Major" };
|
|
1973
|
+
page = { id: ObjectID.generate(), name: "Page" };
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
test("the response shape the dashboard renders is unchanged", async () => {
|
|
1977
|
+
stage({
|
|
1978
|
+
settings: [
|
|
1979
|
+
{
|
|
1980
|
+
ruleType: ComplianceRuleType.HasNotificationEmailMethod,
|
|
1981
|
+
enabled: true,
|
|
1982
|
+
},
|
|
1983
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
1984
|
+
],
|
|
1985
|
+
members: [ada],
|
|
1986
|
+
rules: [],
|
|
1987
|
+
incidentSeverities: [critical],
|
|
1988
|
+
alertSeverities: [],
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
const payload: Record<string, unknown> = await readCompliance();
|
|
1992
|
+
|
|
1993
|
+
expect(Object.keys(payload).sort()).toEqual([
|
|
1994
|
+
"complianceSettings",
|
|
1995
|
+
"teamId",
|
|
1996
|
+
"teamName",
|
|
1997
|
+
"userComplianceStatuses",
|
|
1998
|
+
]);
|
|
1999
|
+
expect(payload["teamId"]).toBe(teamId.toString());
|
|
2000
|
+
expect(payload["teamName"]).toBe("Platform On-Call");
|
|
2001
|
+
expect(payload["complianceSettings"]).toEqual([
|
|
2002
|
+
{
|
|
2003
|
+
ruleType: ComplianceRuleType.HasNotificationEmailMethod,
|
|
2004
|
+
enabled: true,
|
|
2005
|
+
},
|
|
2006
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2007
|
+
]);
|
|
2008
|
+
|
|
2009
|
+
const statuses: Array<Record<string, unknown>> = statusesOf(payload);
|
|
2010
|
+
expect(statuses).toHaveLength(1);
|
|
2011
|
+
expect(Object.keys(statuses[0]!).sort()).toEqual([
|
|
2012
|
+
"isCompliant",
|
|
2013
|
+
"nonCompliantRules",
|
|
2014
|
+
"userEmail",
|
|
2015
|
+
"userId",
|
|
2016
|
+
"userName",
|
|
2017
|
+
"userProfilePictureId",
|
|
2018
|
+
]);
|
|
2019
|
+
expect(statuses[0]!["userId"]).toBe(ada.id.toString());
|
|
2020
|
+
expect(statuses[0]!["userName"]).toBe("Ada");
|
|
2021
|
+
expect(statuses[0]!["isCompliant"]).toBe(false);
|
|
2022
|
+
/*
|
|
2023
|
+
* The reason strings are printed verbatim by TeamComplianceStatusTable, so
|
|
2024
|
+
* their exact wording is part of the payload contract, not an internal
|
|
2025
|
+
* detail. Both an old-style channel rule and a rebuilt on-call rule are
|
|
2026
|
+
* asserted together to show neither vocabulary shifted.
|
|
2027
|
+
*/
|
|
2028
|
+
expect(statuses[0]!["nonCompliantRules"]).toEqual([
|
|
2029
|
+
{
|
|
2030
|
+
ruleType: ComplianceRuleType.HasNotificationEmailMethod,
|
|
2031
|
+
reason: "No verified email address configured for notifications",
|
|
2032
|
+
},
|
|
2033
|
+
{
|
|
2034
|
+
ruleType: ComplianceRuleType.HasIncidentOnCallRules,
|
|
2035
|
+
reason: "Missing notification rules for incident severities: Critical",
|
|
2036
|
+
},
|
|
2037
|
+
]);
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
test("DEFECT closed: a rule on Telegram, WhatsApp or a webhook now counts", async () => {
|
|
2041
|
+
/*
|
|
2042
|
+
* The old check read userCallId/userSmsId/userEmailId/userPushId off the
|
|
2043
|
+
* rule row and treated a row carrying none of them as no rule at all, so a
|
|
2044
|
+
* responder reachable only on Telegram, WhatsApp or a webhook was reported
|
|
2045
|
+
* non-compliant while the runtime was quite happily paging them. A false RED
|
|
2046
|
+
* teaches admins to ignore the table, which is worse than no table.
|
|
2047
|
+
*
|
|
2048
|
+
* All three channels are staged onto one user at once: whichever column the
|
|
2049
|
+
* rule carries, the row is a rule.
|
|
2050
|
+
*/
|
|
2051
|
+
stage({
|
|
2052
|
+
settings: [
|
|
2053
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2054
|
+
{ ruleType: ComplianceRuleType.HasAlertOnCallRules, enabled: true },
|
|
2055
|
+
],
|
|
2056
|
+
members: [ada],
|
|
2057
|
+
rules: [
|
|
2058
|
+
{
|
|
2059
|
+
_id: "rule-telegram",
|
|
2060
|
+
userId: ada.id,
|
|
2061
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2062
|
+
incidentSeverityId: critical.id,
|
|
2063
|
+
userTelegramId: ObjectID.generate(),
|
|
2064
|
+
},
|
|
2065
|
+
{
|
|
2066
|
+
_id: "rule-whatsapp",
|
|
2067
|
+
userId: ada.id,
|
|
2068
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2069
|
+
incidentSeverityId: major.id,
|
|
2070
|
+
userWhatsAppId: ObjectID.generate(),
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
_id: "rule-webhook",
|
|
2074
|
+
userId: ada.id,
|
|
2075
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
2076
|
+
alertSeverityId: page.id,
|
|
2077
|
+
userWebhookId: ObjectID.generate(),
|
|
2078
|
+
},
|
|
2079
|
+
],
|
|
2080
|
+
incidentSeverities: [critical, major],
|
|
2081
|
+
alertSeverities: [page],
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
const statuses: Array<Record<string, unknown>> = statusesOf(
|
|
2085
|
+
await readCompliance(),
|
|
2086
|
+
);
|
|
2087
|
+
|
|
2088
|
+
expect(statuses[0]!["isCompliant"]).toBe(true);
|
|
2089
|
+
expect(statuses[0]!["nonCompliantRules"]).toEqual([]);
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
test("DEFECT closed: no channel column is even read, so none can be missed", async () => {
|
|
2093
|
+
/*
|
|
2094
|
+
* The structural half of the fix. The three formerly-invisible channels were
|
|
2095
|
+
* invisible because they were never SELECTed; asserting that NONE of the
|
|
2096
|
+
* seven is selected means no future edit can reintroduce a partial column
|
|
2097
|
+
* list and quietly start under-counting again.
|
|
2098
|
+
*/
|
|
2099
|
+
stage({
|
|
2100
|
+
settings: [
|
|
2101
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2102
|
+
],
|
|
2103
|
+
members: [ada],
|
|
2104
|
+
rules: [],
|
|
2105
|
+
incidentSeverities: [critical],
|
|
2106
|
+
alertSeverities: [],
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
await readCompliance();
|
|
2110
|
+
|
|
2111
|
+
const call: CapturedFindBy = firstCall(notificationRuleFindBy);
|
|
2112
|
+
|
|
2113
|
+
for (const column of [
|
|
2114
|
+
"userCallId",
|
|
2115
|
+
"userSmsId",
|
|
2116
|
+
"userEmailId",
|
|
2117
|
+
"userPushId",
|
|
2118
|
+
"userTelegramId",
|
|
2119
|
+
"userWhatsAppId",
|
|
2120
|
+
"userWebhookId",
|
|
2121
|
+
]) {
|
|
2122
|
+
expect(call.select?.[column]).toBeUndefined();
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// What IS read: who, what kind of page, which severity, and opt-out state.
|
|
2126
|
+
expect(call.select?.["ruleType"]).toBe(true);
|
|
2127
|
+
expect(call.select?.["incidentSeverityId"]).toBe(true);
|
|
2128
|
+
expect(call.select?.["alertSeverityId"]).toBe(true);
|
|
2129
|
+
expect(call.select?.["isOptOut"]).toBe(true);
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
test("DEFECT closed: a WHEN_USER_GOES_OFF_CALL rule is no longer incident coverage", async () => {
|
|
2133
|
+
/*
|
|
2134
|
+
* The false GREEN, and the more dangerous of the two directions: the owner
|
|
2135
|
+
* was told a responder was covered for Sev1 incidents when their only rule
|
|
2136
|
+
* fired as they went off call. The row below carries an incidentSeverityId,
|
|
2137
|
+
* which is exactly what made the old severity-only match accept it.
|
|
2138
|
+
*/
|
|
2139
|
+
stage({
|
|
2140
|
+
settings: [
|
|
2141
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2142
|
+
],
|
|
2143
|
+
members: [ada],
|
|
2144
|
+
rules: [
|
|
2145
|
+
{
|
|
2146
|
+
_id: "rule-off-call",
|
|
2147
|
+
userId: ada.id,
|
|
2148
|
+
ruleType: NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
2149
|
+
incidentSeverityId: critical.id,
|
|
2150
|
+
userEmailId: ObjectID.generate(),
|
|
2151
|
+
},
|
|
2152
|
+
],
|
|
2153
|
+
incidentSeverities: [critical],
|
|
2154
|
+
alertSeverities: [],
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
const statuses: Array<Record<string, unknown>> = statusesOf(
|
|
2158
|
+
await readCompliance(),
|
|
2159
|
+
);
|
|
2160
|
+
|
|
2161
|
+
expect(statuses[0]!["isCompliant"]).toBe(false);
|
|
2162
|
+
expect(statuses[0]!["nonCompliantRules"]).toEqual([
|
|
2163
|
+
{
|
|
2164
|
+
ruleType: ComplianceRuleType.HasIncidentOnCallRules,
|
|
2165
|
+
reason: "Missing notification rules for incident severities: Critical",
|
|
2166
|
+
},
|
|
2167
|
+
]);
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
test("DEFECT closed: an alert rule does not satisfy an incident severity of the same id", async () => {
|
|
2171
|
+
/*
|
|
2172
|
+
* The sharpest form of the ruleType fix. One rule row, one severity id, and
|
|
2173
|
+
* the two checks disagree about it - because the severity is taken from the
|
|
2174
|
+
* column the RULE TYPE dictates rather than from whichever one happens to be
|
|
2175
|
+
* populated.
|
|
2176
|
+
*/
|
|
2177
|
+
stage({
|
|
2178
|
+
settings: [
|
|
2179
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2180
|
+
{ ruleType: ComplianceRuleType.HasAlertOnCallRules, enabled: true },
|
|
2181
|
+
],
|
|
2182
|
+
members: [ada],
|
|
2183
|
+
rules: [
|
|
2184
|
+
{
|
|
2185
|
+
_id: "rule-alert-only",
|
|
2186
|
+
userId: ada.id,
|
|
2187
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
2188
|
+
alertSeverityId: page.id,
|
|
2189
|
+
userEmailId: ObjectID.generate(),
|
|
2190
|
+
},
|
|
2191
|
+
],
|
|
2192
|
+
incidentSeverities: [critical],
|
|
2193
|
+
alertSeverities: [page],
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
const statuses: Array<Record<string, unknown>> = statusesOf(
|
|
2197
|
+
await readCompliance(),
|
|
2198
|
+
);
|
|
2199
|
+
|
|
2200
|
+
expect(statuses[0]!["nonCompliantRules"]).toEqual([
|
|
2201
|
+
{
|
|
2202
|
+
ruleType: ComplianceRuleType.HasIncidentOnCallRules,
|
|
2203
|
+
reason: "Missing notification rules for incident severities: Critical",
|
|
2204
|
+
},
|
|
2205
|
+
]);
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
test("a legacy rule row with a NULL isOptOut still counts as coverage", async () => {
|
|
2209
|
+
/*
|
|
2210
|
+
* isOptOut is nullable and was added long after these rows started
|
|
2211
|
+
* existing, so it is NULL on every rule in every existing install. An
|
|
2212
|
+
* implementation that classified coverage with `isOptOut === false` would
|
|
2213
|
+
* match none of them and report a fully-configured project as entirely
|
|
2214
|
+
* unready - which is why the split is `isOptOut === true`, the exact dual of
|
|
2215
|
+
* the notInOrNull predicate the paging path uses.
|
|
2216
|
+
*/
|
|
2217
|
+
stage({
|
|
2218
|
+
settings: [
|
|
2219
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2220
|
+
],
|
|
2221
|
+
members: [ada],
|
|
2222
|
+
rules: [
|
|
2223
|
+
{
|
|
2224
|
+
_id: "rule-legacy",
|
|
2225
|
+
userId: ada.id,
|
|
2226
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2227
|
+
incidentSeverityId: critical.id,
|
|
2228
|
+
isOptOut: undefined,
|
|
2229
|
+
userEmailId: ObjectID.generate(),
|
|
2230
|
+
},
|
|
2231
|
+
],
|
|
2232
|
+
incidentSeverities: [critical],
|
|
2233
|
+
alertSeverities: [],
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
const statuses: Array<Record<string, unknown>> = statusesOf(
|
|
2237
|
+
await readCompliance(),
|
|
2238
|
+
);
|
|
2239
|
+
|
|
2240
|
+
expect(statuses[0]!["isCompliant"]).toBe(true);
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
test("an explicitly opted-out severity is coverage, not a gap", async () => {
|
|
2244
|
+
stage({
|
|
2245
|
+
settings: [
|
|
2246
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2247
|
+
],
|
|
2248
|
+
members: [ada],
|
|
2249
|
+
rules: [
|
|
2250
|
+
{
|
|
2251
|
+
_id: "rule-opt-out",
|
|
2252
|
+
userId: ada.id,
|
|
2253
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2254
|
+
incidentSeverityId: critical.id,
|
|
2255
|
+
isOptOut: true,
|
|
2256
|
+
},
|
|
2257
|
+
],
|
|
2258
|
+
incidentSeverities: [critical],
|
|
2259
|
+
alertSeverities: [],
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
const statuses: Array<Record<string, unknown>> = statusesOf(
|
|
2263
|
+
await readCompliance(),
|
|
2264
|
+
);
|
|
2265
|
+
|
|
2266
|
+
// Deliberate silence is not a compliance failure.
|
|
2267
|
+
expect(statuses[0]!["isCompliant"]).toBe(true);
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
test("DEFECT closed: not one read in the whole render carries the literal limit 100", async () => {
|
|
2271
|
+
/*
|
|
2272
|
+
* The old code capped team members, users and alert severities at 100 while
|
|
2273
|
+
* capping incident severities at LIMIT_PER_PROJECT. Truncation is the worst
|
|
2274
|
+
* failure mode a compliance page has: the 101st member was not reported
|
|
2275
|
+
* non-compliant, they were simply absent, and an absent row reads as "no
|
|
2276
|
+
* problem here". Sweeping every read the render makes - the compliance
|
|
2277
|
+
* service's three and the readiness service's dozen - is the assertion that
|
|
2278
|
+
* cannot be satisfied by fixing three call sites and missing a fourth.
|
|
2279
|
+
*/
|
|
2280
|
+
stage({
|
|
2281
|
+
settings: [
|
|
2282
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2283
|
+
{ ruleType: ComplianceRuleType.HasAlertOnCallRules, enabled: true },
|
|
2284
|
+
],
|
|
2285
|
+
members: [ada, grace],
|
|
2286
|
+
rules: [],
|
|
2287
|
+
incidentSeverities: [critical, major],
|
|
2288
|
+
alertSeverities: [page],
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
await readCompliance();
|
|
2292
|
+
|
|
2293
|
+
let readsInspected: number = 0;
|
|
2294
|
+
|
|
2295
|
+
for (const spy of everyFindBySpy()) {
|
|
2296
|
+
for (const call of callsOf(spy)) {
|
|
2297
|
+
readsInspected++;
|
|
2298
|
+
expect(call.limit).not.toBe(100);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// Guards the guard: an assertion over zero reads proves nothing.
|
|
2303
|
+
expect(readsInspected).toBeGreaterThan(10);
|
|
2304
|
+
|
|
2305
|
+
expect(firstCall(complianceSettingFindBy).limit).toBe(LIMIT_PER_PROJECT);
|
|
2306
|
+
expect(firstCall(teamMemberFindBy).limit).toBe(LIMIT_PER_PROJECT);
|
|
2307
|
+
expect(firstCall(userFindBy).limit).toBe(LIMIT_PER_PROJECT);
|
|
2308
|
+
// Both severity kinds, which is where the two halves used to disagree.
|
|
2309
|
+
expect(firstCall(incidentSeverityFindBy).limit).toBe(LIMIT_PER_PROJECT);
|
|
2310
|
+
expect(firstCall(alertSeverityFindBy).limit).toBe(LIMIT_PER_PROJECT);
|
|
2311
|
+
expect(firstCall(notificationRuleFindBy).limit).toBe(LIMIT_PER_PROJECT);
|
|
2312
|
+
});
|
|
2313
|
+
|
|
2314
|
+
test("DEFECT closed: the read count does not grow with members or severities", async () => {
|
|
2315
|
+
/*
|
|
2316
|
+
* The N+1. The old service issued one findBy per severity per user, so a
|
|
2317
|
+
* team of 20 in a project with 5 severities cost 100 round trips to render
|
|
2318
|
+
* one page. The proof is comparative rather than absolute: the same render
|
|
2319
|
+
* with three times the members and three times the severities must cost the
|
|
2320
|
+
* same number of reads, whatever that number happens to be.
|
|
2321
|
+
*/
|
|
2322
|
+
stage({
|
|
2323
|
+
settings: [
|
|
2324
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2325
|
+
],
|
|
2326
|
+
members: [ada],
|
|
2327
|
+
rules: [],
|
|
2328
|
+
incidentSeverities: [critical],
|
|
2329
|
+
alertSeverities: [],
|
|
2330
|
+
});
|
|
2331
|
+
|
|
2332
|
+
await readCompliance();
|
|
2333
|
+
|
|
2334
|
+
const smallRuleReads: number = notificationRuleFindBy.mock.calls.length;
|
|
2335
|
+
const smallUserReads: number = userFindBy.mock.calls.length;
|
|
2336
|
+
const smallSeverityReads: number =
|
|
2337
|
+
incidentSeverityFindBy.mock.calls.length +
|
|
2338
|
+
alertSeverityFindBy.mock.calls.length;
|
|
2339
|
+
|
|
2340
|
+
expect(smallRuleReads).toBe(1);
|
|
2341
|
+
|
|
2342
|
+
/*
|
|
2343
|
+
* mockClear, not mockReset: the stubs' resolved values are implementations
|
|
2344
|
+
* and have to survive into the second render.
|
|
2345
|
+
*/
|
|
2346
|
+
jest.clearAllMocks();
|
|
2347
|
+
OnCallReadinessService.clearCache();
|
|
2348
|
+
|
|
2349
|
+
const carol: StubUser = {
|
|
2350
|
+
id: ObjectID.generate(),
|
|
2351
|
+
name: "Carol",
|
|
2352
|
+
email: "carol@example.com",
|
|
2353
|
+
};
|
|
2354
|
+
const minor: StubSeverity = { id: ObjectID.generate(), name: "Minor" };
|
|
2355
|
+
const trivial: StubSeverity = { id: ObjectID.generate(), name: "Trivial" };
|
|
2356
|
+
|
|
2357
|
+
stage({
|
|
2358
|
+
settings: [
|
|
2359
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2360
|
+
],
|
|
2361
|
+
members: [ada, grace, carol],
|
|
2362
|
+
rules: [],
|
|
2363
|
+
incidentSeverities: [critical, major, minor, trivial],
|
|
2364
|
+
alertSeverities: [page],
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
await readCompliance();
|
|
2368
|
+
|
|
2369
|
+
expect(notificationRuleFindBy.mock.calls.length).toBe(smallRuleReads);
|
|
2370
|
+
expect(userFindBy.mock.calls.length).toBe(smallUserReads);
|
|
2371
|
+
expect(
|
|
2372
|
+
incidentSeverityFindBy.mock.calls.length +
|
|
2373
|
+
alertSeverityFindBy.mock.calls.length,
|
|
2374
|
+
).toBe(smallSeverityReads);
|
|
2375
|
+
});
|
|
2376
|
+
|
|
2377
|
+
test("one notification-rule read covers every member, batched on userId", async () => {
|
|
2378
|
+
stage({
|
|
2379
|
+
settings: [
|
|
2380
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: true },
|
|
2381
|
+
],
|
|
2382
|
+
members: [ada, grace],
|
|
2383
|
+
rules: [
|
|
2384
|
+
{
|
|
2385
|
+
_id: "rule-ada",
|
|
2386
|
+
userId: ada.id,
|
|
2387
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2388
|
+
incidentSeverityId: critical.id,
|
|
2389
|
+
userEmailId: ObjectID.generate(),
|
|
2390
|
+
},
|
|
2391
|
+
],
|
|
2392
|
+
incidentSeverities: [critical],
|
|
2393
|
+
alertSeverities: [],
|
|
2394
|
+
});
|
|
2395
|
+
|
|
2396
|
+
const statuses: Array<Record<string, unknown>> = statusesOf(
|
|
2397
|
+
await readCompliance(),
|
|
2398
|
+
);
|
|
2399
|
+
|
|
2400
|
+
expect(notificationRuleFindBy).toHaveBeenCalledTimes(1);
|
|
2401
|
+
|
|
2402
|
+
const call: CapturedFindBy = firstCall(notificationRuleFindBy);
|
|
2403
|
+
// One query, scoped to the project, listing every user it is asking about.
|
|
2404
|
+
expect(call.query["userId"]).toBeDefined();
|
|
2405
|
+
expect(call.props?.isRoot).toBe(true);
|
|
2406
|
+
|
|
2407
|
+
/*
|
|
2408
|
+
* And the batched read still separates the two people: Ada's rule must not
|
|
2409
|
+
* cover Grace. A batched query folded into a per-user map is exactly where
|
|
2410
|
+
* that mistake would hide.
|
|
2411
|
+
*/
|
|
2412
|
+
expect(statuses[0]!["userName"]).toBe("Ada");
|
|
2413
|
+
expect(statuses[0]!["isCompliant"]).toBe(true);
|
|
2414
|
+
expect(statuses[1]!["userName"]).toBe("Grace");
|
|
2415
|
+
expect(statuses[1]!["isCompliant"]).toBe(false);
|
|
2416
|
+
});
|
|
2417
|
+
|
|
2418
|
+
test("readiness is not computed at all when no on-call rule is enabled", async () => {
|
|
2419
|
+
/*
|
|
2420
|
+
* The four channel rules do not consult readiness, so a team that only
|
|
2421
|
+
* checks "has a verified email" must not pay for a project-wide readiness
|
|
2422
|
+
* pass on every render.
|
|
2423
|
+
*/
|
|
2424
|
+
stage({
|
|
2425
|
+
settings: [
|
|
2426
|
+
{
|
|
2427
|
+
ruleType: ComplianceRuleType.HasNotificationEmailMethod,
|
|
2428
|
+
enabled: true,
|
|
2429
|
+
},
|
|
2430
|
+
{ ruleType: ComplianceRuleType.HasIncidentOnCallRules, enabled: false },
|
|
2431
|
+
],
|
|
2432
|
+
members: [ada],
|
|
2433
|
+
rules: [],
|
|
2434
|
+
incidentSeverities: [critical],
|
|
2435
|
+
alertSeverities: [],
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
await readCompliance();
|
|
2439
|
+
|
|
2440
|
+
expect(notificationRuleFindBy).not.toHaveBeenCalled();
|
|
2441
|
+
expect(incidentSeverityFindBy).not.toHaveBeenCalled();
|
|
2442
|
+
expect(escalationUserFindBy).not.toHaveBeenCalled();
|
|
2443
|
+
expect(projectFindOneById).not.toHaveBeenCalled();
|
|
2444
|
+
// The channel rule it WAS asked about still runs.
|
|
2445
|
+
expect(userEmailFindBy).toHaveBeenCalledTimes(1);
|
|
2446
|
+
});
|
|
2447
|
+
|
|
2448
|
+
test("a team that does not exist is refused rather than described", async () => {
|
|
2449
|
+
/*
|
|
2450
|
+
* Refused by the ROUTE now, before the service is reached, and with the same
|
|
2451
|
+
* words a team in another project gets - see the authorisation block below
|
|
2452
|
+
* for why those two cases must be indistinguishable. The service refuses it
|
|
2453
|
+
* as well, on its own, with a BadDataException; that guard is pinned in
|
|
2454
|
+
* TeamComplianceServiceBehaviour.test.ts because it protects in-process
|
|
2455
|
+
* callers who never come through this route.
|
|
2456
|
+
*/
|
|
2457
|
+
teamFindOneById.mockResolvedValue(null as never);
|
|
2458
|
+
|
|
2459
|
+
const result: RouteCallResult = await callGetRoute({
|
|
2460
|
+
uri: COMPLIANCE_ROUTE,
|
|
2461
|
+
params: { teamId: teamId.toString() },
|
|
2462
|
+
});
|
|
2463
|
+
|
|
2464
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
2465
|
+
expect((result.thrownToNext as NotAuthorizedException).message).toBe(
|
|
2466
|
+
REFUSAL,
|
|
2467
|
+
);
|
|
2468
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
2469
|
+
});
|
|
2470
|
+
});
|
|
2471
|
+
|
|
2472
|
+
/*
|
|
2473
|
+
* --------------------------------------------------------------------------- *
|
|
2474
|
+
* GET /team/compliance-status/:teamId - authorisation.
|
|
2475
|
+
*
|
|
2476
|
+
* PRE-EXISTING HOLE, not a Phase 2 regression, closed here because Phase 2
|
|
2477
|
+
* rebuilt what sits behind this route.
|
|
2478
|
+
*
|
|
2479
|
+
* The route was mounted with UserMiddleware.getUserMiddleware - which admits an
|
|
2480
|
+
* anonymous request as UserType.Public and calls next() - and then checked only
|
|
2481
|
+
* that `databaseProps.tenantId` was non-empty. tenantId comes from a
|
|
2482
|
+
* caller-supplied `tenantid` header. Everything underneath reads with
|
|
2483
|
+
* isRoot: true. So a `tenantid` header and a team id were the entire
|
|
2484
|
+
* authentication story for "which of these named people cannot be paged", which
|
|
2485
|
+
* is a roster of who to phone during an outage and who will never pick up.
|
|
2486
|
+
*
|
|
2487
|
+
* Worse in combination: the service read the team by id with no projectId in the
|
|
2488
|
+
* query, so the team did not even have to belong to the project in the header.
|
|
2489
|
+
*
|
|
2490
|
+
* Both halves are tested here - the caller has to be a member, AND the team has
|
|
2491
|
+
* to be theirs - and the refusals are asserted to be word-for-word identical so
|
|
2492
|
+
* the route cannot be used to enumerate team ids across tenants.
|
|
2493
|
+
* ---------------------------------------------------------------------------
|
|
2494
|
+
*/
|
|
2495
|
+
|
|
2496
|
+
describe("GET /team/compliance-status/:teamId - authorisation", () => {
|
|
2497
|
+
let complianceSpy: jest.SpyInstance;
|
|
2498
|
+
|
|
2499
|
+
beforeEach(() => {
|
|
2500
|
+
complianceSpy = jest.spyOn(
|
|
2501
|
+
TeamComplianceService,
|
|
2502
|
+
"getTeamComplianceStatus",
|
|
2503
|
+
);
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
test("refuses an unauthenticated caller before reading anything", async () => {
|
|
2507
|
+
propsSpy.mockResolvedValue({} as never);
|
|
2508
|
+
|
|
2509
|
+
const result: RouteCallResult = await callGetRoute({
|
|
2510
|
+
uri: COMPLIANCE_ROUTE,
|
|
2511
|
+
params: { teamId: teamId.toString() },
|
|
2512
|
+
});
|
|
2513
|
+
|
|
2514
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
2515
|
+
expect(teamFindOneById).not.toHaveBeenCalled();
|
|
2516
|
+
expect(complianceSpy).not.toHaveBeenCalled();
|
|
2517
|
+
});
|
|
2518
|
+
|
|
2519
|
+
test("refuses a public caller that merely supplies a tenantid header", async () => {
|
|
2520
|
+
/*
|
|
2521
|
+
* THE hole, in its exact shape: the request getUserMiddleware produces for
|
|
2522
|
+
* an anonymous caller who sent a `tenantid` header. A project id, no user,
|
|
2523
|
+
* no permissions - and, until this change, a complete compliance report.
|
|
2524
|
+
*/
|
|
2525
|
+
propsSpy.mockResolvedValue({
|
|
2526
|
+
tenantId: projectId,
|
|
2527
|
+
userId: undefined,
|
|
2528
|
+
userTenantAccessPermission: undefined,
|
|
2529
|
+
} as never);
|
|
2530
|
+
|
|
2531
|
+
const result: RouteCallResult = await callGetRoute({
|
|
2532
|
+
uri: COMPLIANCE_ROUTE,
|
|
2533
|
+
params: { teamId: teamId.toString() },
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
2537
|
+
expect((result.thrownToNext as NotAuthorizedException).message).toBe(
|
|
2538
|
+
REFUSAL,
|
|
2539
|
+
);
|
|
2540
|
+
expect(teamFindOneById).not.toHaveBeenCalled();
|
|
2541
|
+
expect(complianceSpy).not.toHaveBeenCalled();
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
test("refuses a logged-in caller whose tenantid names a project they are not in", async () => {
|
|
2545
|
+
propsSpy.mockResolvedValue({
|
|
2546
|
+
tenantId: projectId,
|
|
2547
|
+
userId: callerUserId,
|
|
2548
|
+
// Logged in, but with no permission entry for the project they named.
|
|
2549
|
+
userTenantAccessPermission: {},
|
|
2550
|
+
} as never);
|
|
2551
|
+
|
|
2552
|
+
const result: RouteCallResult = await callGetRoute({
|
|
2553
|
+
uri: COMPLIANCE_ROUTE,
|
|
2554
|
+
params: { teamId: teamId.toString() },
|
|
2555
|
+
});
|
|
2556
|
+
|
|
2557
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
2558
|
+
expect(complianceSpy).not.toHaveBeenCalled();
|
|
2559
|
+
});
|
|
2560
|
+
|
|
2561
|
+
test("a member of one project cannot read another project's team", async () => {
|
|
2562
|
+
/*
|
|
2563
|
+
* The caller is a real member of their own project; only the team id is
|
|
2564
|
+
* borrowed. Without the resource check this is the whole exploit - every
|
|
2565
|
+
* read underneath runs as root, so the borrowed id resolves and the report
|
|
2566
|
+
* is rendered for a team the caller has nothing to do with.
|
|
2567
|
+
*/
|
|
2568
|
+
teamFindOneById.mockResolvedValue({
|
|
2569
|
+
_id: teamId.toString(),
|
|
2570
|
+
projectId: otherProjectId,
|
|
2571
|
+
} as never);
|
|
2572
|
+
|
|
2573
|
+
const result: RouteCallResult = await callGetRoute({
|
|
2574
|
+
uri: COMPLIANCE_ROUTE,
|
|
2575
|
+
params: { teamId: teamId.toString() },
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
2579
|
+
expect((result.thrownToNext as NotAuthorizedException).message).toBe(
|
|
2580
|
+
REFUSAL,
|
|
2581
|
+
);
|
|
2582
|
+
expect(complianceSpy).not.toHaveBeenCalled();
|
|
2583
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
test("a foreign team and an unknown team are refused in identical words", async () => {
|
|
2587
|
+
teamFindOneById.mockResolvedValue({
|
|
2588
|
+
_id: teamId.toString(),
|
|
2589
|
+
projectId: otherProjectId,
|
|
2590
|
+
} as never);
|
|
2591
|
+
const foreign: RouteCallResult = await callGetRoute({
|
|
2592
|
+
uri: COMPLIANCE_ROUTE,
|
|
2593
|
+
params: { teamId: teamId.toString() },
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
teamFindOneById.mockResolvedValue(null as never);
|
|
2597
|
+
const unknown: RouteCallResult = await callGetRoute({
|
|
2598
|
+
uri: COMPLIANCE_ROUTE,
|
|
2599
|
+
params: { teamId: ObjectID.generate().toString() },
|
|
2600
|
+
});
|
|
2601
|
+
|
|
2602
|
+
expect((foreign.thrownToNext as NotAuthorizedException).message).toBe(
|
|
2603
|
+
(unknown.thrownToNext as NotAuthorizedException).message,
|
|
2604
|
+
);
|
|
2605
|
+
expect((foreign.thrownToNext as NotAuthorizedException).message).toBe(
|
|
2606
|
+
REFUSAL,
|
|
2607
|
+
);
|
|
2608
|
+
});
|
|
2609
|
+
|
|
2610
|
+
test("a team row carrying no projectId at all is refused, not trusted", async () => {
|
|
2611
|
+
// The shape a `select` that forgot projectId would produce.
|
|
2612
|
+
teamFindOneById.mockResolvedValue({ _id: teamId.toString() } as never);
|
|
2613
|
+
|
|
2614
|
+
const result: RouteCallResult = await callGetRoute({
|
|
2615
|
+
uri: COMPLIANCE_ROUTE,
|
|
2616
|
+
params: { teamId: teamId.toString() },
|
|
2617
|
+
});
|
|
2618
|
+
|
|
2619
|
+
expect(result.thrownToNext).toBeInstanceOf(NotAuthorizedException);
|
|
2620
|
+
expect(complianceSpy).not.toHaveBeenCalled();
|
|
2621
|
+
});
|
|
2622
|
+
|
|
2623
|
+
test("a malformed team id is rejected as bad data, not turned into a query", async () => {
|
|
2624
|
+
/*
|
|
2625
|
+
* The old handler built `new ObjectID(req.params.teamId)` from any string
|
|
2626
|
+
* and then tested it for truthiness - which an ObjectID always is - so a
|
|
2627
|
+
* malformed id travelled all the way to a query that matched nothing and
|
|
2628
|
+
* came back as "this team does not exist".
|
|
2629
|
+
*/
|
|
2630
|
+
const result: RouteCallResult = await callGetRoute({
|
|
2631
|
+
uri: COMPLIANCE_ROUTE,
|
|
2632
|
+
params: { teamId: "not-a-uuid" },
|
|
2633
|
+
});
|
|
2634
|
+
|
|
2635
|
+
expect(result.thrownToNext).toBeInstanceOf(BadDataException);
|
|
2636
|
+
expect(teamFindOneById).not.toHaveBeenCalled();
|
|
2637
|
+
expect(complianceSpy).not.toHaveBeenCalled();
|
|
2638
|
+
});
|
|
2639
|
+
|
|
2640
|
+
test("the authorised path reads the team's own projectId, as root, and proceeds", async () => {
|
|
2641
|
+
const result: RouteCallResult = await callGetRoute({
|
|
2642
|
+
uri: COMPLIANCE_ROUTE,
|
|
2643
|
+
params: { teamId: teamId.toString() },
|
|
2644
|
+
});
|
|
2645
|
+
|
|
2646
|
+
expect(result.nextCallCount).toBe(0);
|
|
2647
|
+
|
|
2648
|
+
const read: {
|
|
2649
|
+
id: ObjectID;
|
|
2650
|
+
select: Record<string, unknown>;
|
|
2651
|
+
props: { isRoot?: boolean | undefined };
|
|
2652
|
+
} = teamFindOneById.mock.calls[0]![0] as {
|
|
2653
|
+
id: ObjectID;
|
|
2654
|
+
select: Record<string, unknown>;
|
|
2655
|
+
props: { isRoot?: boolean | undefined };
|
|
2656
|
+
};
|
|
2657
|
+
|
|
2658
|
+
expect(read.id.toString()).toBe(teamId.toString());
|
|
2659
|
+
expect(read.select["projectId"]).toBe(true);
|
|
2660
|
+
expect(read.props.isRoot).toBe(true);
|
|
2661
|
+
|
|
2662
|
+
// And the service is asked about the project the CALLER was authorised for.
|
|
2663
|
+
expect(complianceSpy).toHaveBeenCalledTimes(1);
|
|
2664
|
+
expect(complianceSpy.mock.calls[0]![1]).toEqual(projectId);
|
|
2665
|
+
|
|
2666
|
+
/*
|
|
2667
|
+
* Underneath, the service scopes its OWN team read to id and project
|
|
2668
|
+
* together. The route's check and this one are not redundant: the route's
|
|
2669
|
+
* answers an authorisation question (403, and only for HTTP callers), this
|
|
2670
|
+
* one is a data-scoping guard that also covers the on-call banner and any
|
|
2671
|
+
* other in-process caller that never passes through the route at all.
|
|
2672
|
+
*/
|
|
2673
|
+
const scoped: CapturedFindBy = firstCall(teamFindOneBy);
|
|
2674
|
+
expect(scoped.query["_id"]).toBe(teamId.toString());
|
|
2675
|
+
expect((scoped.query["projectId"] as ObjectID).toString()).toBe(
|
|
2676
|
+
projectId.toString(),
|
|
2677
|
+
);
|
|
2678
|
+
expect(scoped.props?.isRoot).toBe(true);
|
|
2679
|
+
});
|
|
2680
|
+
});
|