@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,2848 @@
|
|
|
1
|
+
import DatabaseConfig from "../../../Server/DatabaseConfig";
|
|
2
|
+
import AuditLogService from "../../../Server/Services/AuditLogService";
|
|
3
|
+
import MailService from "../../../Server/Services/MailService";
|
|
4
|
+
import ProjectService from "../../../Server/Services/ProjectService";
|
|
5
|
+
import TeamMemberService from "../../../Server/Services/TeamMemberService";
|
|
6
|
+
import UserCallService from "../../../Server/Services/UserCallService";
|
|
7
|
+
import UserEmailService from "../../../Server/Services/UserEmailService";
|
|
8
|
+
import UserNotificationRuleAdminService from "../../../Server/Services/UserNotificationRuleAdminService";
|
|
9
|
+
import UserNotificationRuleService, {
|
|
10
|
+
ExecuteNotificationRuleOptions,
|
|
11
|
+
} from "../../../Server/Services/UserNotificationRuleService";
|
|
12
|
+
import UserOnCallLogService from "../../../Server/Services/UserOnCallLogService";
|
|
13
|
+
import UserOnCallLogTimelineService from "../../../Server/Services/UserOnCallLogTimelineService";
|
|
14
|
+
import UserPushService from "../../../Server/Services/UserPushService";
|
|
15
|
+
import UserService from "../../../Server/Services/UserService";
|
|
16
|
+
import UserSmsService from "../../../Server/Services/UserSmsService";
|
|
17
|
+
import UserTelegramService from "../../../Server/Services/UserTelegramService";
|
|
18
|
+
import UserWebhookService from "../../../Server/Services/UserWebhookService";
|
|
19
|
+
import UserWhatsAppService from "../../../Server/Services/UserWhatsAppService";
|
|
20
|
+
import CreateBy from "../../../Server/Types/Database/CreateBy";
|
|
21
|
+
import DeleteBy from "../../../Server/Types/Database/DeleteBy";
|
|
22
|
+
import {
|
|
23
|
+
OnCreate,
|
|
24
|
+
OnDelete,
|
|
25
|
+
OnUpdate,
|
|
26
|
+
} from "../../../Server/Types/Database/Hooks";
|
|
27
|
+
import UpdateBy from "../../../Server/Types/Database/UpdateBy";
|
|
28
|
+
import logger from "../../../Server/Utils/Logger";
|
|
29
|
+
import Project from "../../../Models/DatabaseModels/Project";
|
|
30
|
+
import TeamMember from "../../../Models/DatabaseModels/TeamMember";
|
|
31
|
+
import User from "../../../Models/DatabaseModels/User";
|
|
32
|
+
import UserNotificationRule from "../../../Models/DatabaseModels/UserNotificationRule";
|
|
33
|
+
import UserOnCallLogTimeline from "../../../Models/DatabaseModels/UserOnCallLogTimeline";
|
|
34
|
+
import URL from "../../../Types/API/URL";
|
|
35
|
+
import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
36
|
+
import Email from "../../../Types/Email";
|
|
37
|
+
import Name from "../../../Types/Name";
|
|
38
|
+
import NotificationRuleType from "../../../Types/NotificationRule/NotificationRuleType";
|
|
39
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
40
|
+
import Permission from "../../../Types/Permission";
|
|
41
|
+
import UserNotificationEventType from "../../../Types/UserNotification/UserNotificationEventType";
|
|
42
|
+
import UserNotificationStatus from "../../../Types/UserNotification/UserNotificationStatus";
|
|
43
|
+
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* WHAT THIS FILE IS DEFENDING, AND WHY IT IS WRITTEN THE WAY IT IS
|
|
47
|
+
*
|
|
48
|
+
* A UserNotificationRule row is two halves that no screen, no ORM relation and
|
|
49
|
+
* no permission rule ever compares against each other:
|
|
50
|
+
*
|
|
51
|
+
* userId -> whose on-call page selects this row
|
|
52
|
+
* user<X>Id -> which address that page is delivered to
|
|
53
|
+
*
|
|
54
|
+
* For as long as every notification model was CurrentUser-only, the two halves
|
|
55
|
+
* could not disagree: a caller could only ever name their own id in both. This
|
|
56
|
+
* phase deliberately lets a project administrator write a row whose userId is
|
|
57
|
+
* somebody else's, because an admin who cannot see or repair a responder's
|
|
58
|
+
* broken notification setup cannot actually fix the outage-shaped problem this
|
|
59
|
+
* epic is about. The moment that is true, "userId = Bob, userWebhookId = the
|
|
60
|
+
* admin's own webhook" becomes an input somebody can send. It routes every one
|
|
61
|
+
* of Bob's pages to an endpoint the sender controls, and it is invisible from
|
|
62
|
+
* both ends: Bob's rules page still lists a rule, the on-call log still records
|
|
63
|
+
* a delivered notification, and the page simply reaches the wrong human during
|
|
64
|
+
* the incident where it mattered.
|
|
65
|
+
*
|
|
66
|
+
* The permission layer structurally cannot catch that. It reasons about tables
|
|
67
|
+
* and columns; this is a relationship between two VALUES in one row. So the
|
|
68
|
+
* invariants live in the service hooks, and this file is their adversarial
|
|
69
|
+
* test: it sends the requests an attacker would send rather than the ones a
|
|
70
|
+
* dashboard would.
|
|
71
|
+
*
|
|
72
|
+
* R1 the rule's owner must be on the acting project's roster, because
|
|
73
|
+
* holding an administrative permission is a claim about ONE project and
|
|
74
|
+
* a user id is global.
|
|
75
|
+
* R3 a rule's method FK must point at a method row owned by the rule's own
|
|
76
|
+
* user - on create, on update, through the FK column and through the
|
|
77
|
+
* relation slot, on every one of the seven channels, and on update with
|
|
78
|
+
* the owner re-read from the DATABASE because the body is written by the
|
|
79
|
+
* party under suspicion.
|
|
80
|
+
* R6 the audit line and the owner's warning email are keyed on the actor the
|
|
81
|
+
* SERVER resolved against the owner the row was PERSISTED with; never on
|
|
82
|
+
* anything the request said, and never at the cost of the write itself.
|
|
83
|
+
* D-i-D a row that is already mismatched - written before these guards
|
|
84
|
+
* existed, or by internal code running as root - must be inert at
|
|
85
|
+
* delivery time rather than quietly obeyed.
|
|
86
|
+
*
|
|
87
|
+
* Every happy path here earns its place by proving a guard does not simply
|
|
88
|
+
* refuse everything. A guard that blocks legitimate repair is deleted by the
|
|
89
|
+
* next engineer who needs to repair something, and then there is no guard.
|
|
90
|
+
*
|
|
91
|
+
* No database is involved: each service the guards consult is a jest.spyOn
|
|
92
|
+
* stub, and the hooks - which are protected, and are ordinary prototype methods
|
|
93
|
+
* at runtime - are reached through a structural cast.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
97
|
+
"aaaa1111-1111-4111-8111-111111111111",
|
|
98
|
+
);
|
|
99
|
+
const OTHER_PROJECT_ID: ObjectID = new ObjectID(
|
|
100
|
+
"bbbb1111-1111-4111-8111-111111111111",
|
|
101
|
+
);
|
|
102
|
+
const ADMIN_USER_ID: ObjectID = new ObjectID(
|
|
103
|
+
"aaaa2222-2222-4222-8222-222222222222",
|
|
104
|
+
);
|
|
105
|
+
const VICTIM_USER_ID: ObjectID = new ObjectID(
|
|
106
|
+
"aaaa3333-3333-4333-8333-333333333333",
|
|
107
|
+
);
|
|
108
|
+
const STRANGER_USER_ID: ObjectID = new ObjectID(
|
|
109
|
+
"aaaa4444-4444-4444-8444-444444444444",
|
|
110
|
+
);
|
|
111
|
+
const VICTIM_METHOD_ID: ObjectID = new ObjectID(
|
|
112
|
+
"aaaa5555-5555-4555-8555-555555555555",
|
|
113
|
+
);
|
|
114
|
+
const ADMIN_METHOD_ID: ObjectID = new ObjectID(
|
|
115
|
+
"aaaa6666-6666-4666-8666-666666666666",
|
|
116
|
+
);
|
|
117
|
+
const RULE_ID: ObjectID = new ObjectID("aaaa7777-7777-4777-8777-777777777777");
|
|
118
|
+
const SECOND_RULE_ID: ObjectID = new ObjectID(
|
|
119
|
+
"aaaa8888-8888-4888-8888-888888888888",
|
|
120
|
+
);
|
|
121
|
+
const TEAM_MEMBER_ID: ObjectID = new ObjectID(
|
|
122
|
+
"aaaa9999-9999-4999-8999-999999999999",
|
|
123
|
+
);
|
|
124
|
+
const ON_CALL_LOG_ID: ObjectID = new ObjectID(
|
|
125
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
126
|
+
);
|
|
127
|
+
const TIMELINE_ID: ObjectID = new ObjectID(
|
|
128
|
+
"aaaabbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
129
|
+
);
|
|
130
|
+
const INCIDENT_ID: ObjectID = new ObjectID(
|
|
131
|
+
"aaaacccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const ADMIN_EMAIL: string = "ada.admin@example.test";
|
|
135
|
+
const VICTIM_EMAIL: string = "vic.responder@example.test";
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* The seven channels, each named by the FK column that carries it, the relation
|
|
139
|
+
* slot that is the same write spelled differently, the service the guard has to
|
|
140
|
+
* consult for that channel's owner, and the word that must appear in the
|
|
141
|
+
* refusal so an operator can tell WHICH method was wrong.
|
|
142
|
+
*
|
|
143
|
+
* Everything about method ownership is driven off this table on purpose. The
|
|
144
|
+
* one realistic way for the guard to be wrong is to cover six channels and
|
|
145
|
+
* forget the seventh, and an attacker choosing between seven doors will always
|
|
146
|
+
* choose the unlocked one - so a per-channel loop is the only shape of test
|
|
147
|
+
* that means anything here.
|
|
148
|
+
*/
|
|
149
|
+
interface MethodServiceLike {
|
|
150
|
+
findOneById: (...args: Array<never>) => unknown;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface ChannelFixture {
|
|
154
|
+
idColumn: string;
|
|
155
|
+
relationColumn: string;
|
|
156
|
+
label: string;
|
|
157
|
+
service: MethodServiceLike;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const CHANNELS: Array<ChannelFixture> = [
|
|
161
|
+
{
|
|
162
|
+
idColumn: "userEmailId",
|
|
163
|
+
relationColumn: "userEmail",
|
|
164
|
+
label: "Email",
|
|
165
|
+
service: UserEmailService as unknown as MethodServiceLike,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
idColumn: "userSmsId",
|
|
169
|
+
relationColumn: "userSms",
|
|
170
|
+
label: "SMS",
|
|
171
|
+
service: UserSmsService as unknown as MethodServiceLike,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
idColumn: "userCallId",
|
|
175
|
+
relationColumn: "userCall",
|
|
176
|
+
label: "Call",
|
|
177
|
+
service: UserCallService as unknown as MethodServiceLike,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
idColumn: "userWhatsAppId",
|
|
181
|
+
relationColumn: "userWhatsApp",
|
|
182
|
+
label: "WhatsApp",
|
|
183
|
+
service: UserWhatsAppService as unknown as MethodServiceLike,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
idColumn: "userTelegramId",
|
|
187
|
+
relationColumn: "userTelegram",
|
|
188
|
+
label: "Telegram",
|
|
189
|
+
service: UserTelegramService as unknown as MethodServiceLike,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
idColumn: "userPushId",
|
|
193
|
+
relationColumn: "userPush",
|
|
194
|
+
label: "Push",
|
|
195
|
+
service: UserPushService as unknown as MethodServiceLike,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
idColumn: "userWebhookId",
|
|
199
|
+
relationColumn: "userWebhook",
|
|
200
|
+
label: "Webhook",
|
|
201
|
+
service: UserWebhookService as unknown as MethodServiceLike,
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
/*
|
|
206
|
+
* The four write hooks and the private delivery half, named through a
|
|
207
|
+
* structural cast. TypeScript forbids a test naming a protected member; the
|
|
208
|
+
* runtime does not care, and testing these through `create()` / `updateBy()`
|
|
209
|
+
* instead would drag in the entire ORM for no extra assurance about the one
|
|
210
|
+
* comparison under test.
|
|
211
|
+
*/
|
|
212
|
+
interface RuleServiceInternals {
|
|
213
|
+
onBeforeCreate: (
|
|
214
|
+
createBy: CreateBy<UserNotificationRule>,
|
|
215
|
+
) => Promise<OnCreate<UserNotificationRule>>;
|
|
216
|
+
onCreateSuccess: (
|
|
217
|
+
onCreate: OnCreate<UserNotificationRule>,
|
|
218
|
+
createdItem: UserNotificationRule,
|
|
219
|
+
) => Promise<UserNotificationRule>;
|
|
220
|
+
onBeforeUpdate: (
|
|
221
|
+
updateBy: UpdateBy<UserNotificationRule>,
|
|
222
|
+
) => Promise<OnUpdate<UserNotificationRule>>;
|
|
223
|
+
onUpdateSuccess: (
|
|
224
|
+
onUpdate: OnUpdate<UserNotificationRule>,
|
|
225
|
+
updatedItemIds: Array<ObjectID>,
|
|
226
|
+
) => Promise<OnUpdate<UserNotificationRule>>;
|
|
227
|
+
onBeforeDelete: (
|
|
228
|
+
deleteBy: DeleteBy<UserNotificationRule>,
|
|
229
|
+
) => Promise<OnDelete<UserNotificationRule>>;
|
|
230
|
+
onDeleteSuccess: (
|
|
231
|
+
onDelete: OnDelete<UserNotificationRule>,
|
|
232
|
+
deletedItemIds: Array<ObjectID>,
|
|
233
|
+
) => Promise<OnDelete<UserNotificationRule>>;
|
|
234
|
+
deliverNotificationForRule: (
|
|
235
|
+
notificationRuleItem: UserNotificationRule,
|
|
236
|
+
options: ExecuteNotificationRuleOptions,
|
|
237
|
+
) => Promise<boolean>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function ruleService(): RuleServiceInternals {
|
|
241
|
+
return UserNotificationRuleService as unknown as RuleServiceInternals;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/*
|
|
245
|
+
* A member session: a real actor id and a real tenant, exactly like the API
|
|
246
|
+
* builds. It carries no ROLE permission, which makes it the ordinary-member
|
|
247
|
+
* case - Permission.CurrentUser is auto-granted to every authenticated caller
|
|
248
|
+
* and is the only thing letting this session through the model's write lists.
|
|
249
|
+
*/
|
|
250
|
+
function adminProps(
|
|
251
|
+
overrides: Partial<DatabaseCommonInteractionProps> = {},
|
|
252
|
+
): DatabaseCommonInteractionProps {
|
|
253
|
+
return {
|
|
254
|
+
userId: ADMIN_USER_ID,
|
|
255
|
+
tenantId: PROJECT_ID,
|
|
256
|
+
...overrides,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/*
|
|
261
|
+
* A session that holds a genuine ProjectAdmin row on this project, which is
|
|
262
|
+
* what a real administrator's session looks like once the API has resolved
|
|
263
|
+
* their team memberships.
|
|
264
|
+
*
|
|
265
|
+
* The distinction from adminProps above is exactly the distinction
|
|
266
|
+
* TenantPermission.isAccessGrantedOnlyByCurrentUser draws, and the whole
|
|
267
|
+
* feature turns on it: a session holding only auto-granted permissions is
|
|
268
|
+
* confined to its own rows, while one holding a role permission that appears in
|
|
269
|
+
* the model's list is not - which is what lets an administrator see and repair
|
|
270
|
+
* somebody else's paging in the first place.
|
|
271
|
+
*/
|
|
272
|
+
function projectAdminProps(
|
|
273
|
+
overrides: Partial<DatabaseCommonInteractionProps> = {},
|
|
274
|
+
): DatabaseCommonInteractionProps {
|
|
275
|
+
return {
|
|
276
|
+
userId: ADMIN_USER_ID,
|
|
277
|
+
tenantId: PROJECT_ID,
|
|
278
|
+
userTenantAccessPermission: {
|
|
279
|
+
[PROJECT_ID.toString()]: {
|
|
280
|
+
_type: "UserTenantAccessPermission",
|
|
281
|
+
projectId: PROJECT_ID,
|
|
282
|
+
permissions: [
|
|
283
|
+
{
|
|
284
|
+
_type: "UserPermission",
|
|
285
|
+
permission: Permission.ProjectAdmin,
|
|
286
|
+
labelIds: [],
|
|
287
|
+
isBlockPermission: false,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
...overrides,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/*
|
|
297
|
+
* A create payload as the hook receives it: a real model instance, not a
|
|
298
|
+
* literal, because the guard reads relation slots as well as FK columns and the
|
|
299
|
+
* hook ahead of it reads the model's own accessors.
|
|
300
|
+
*/
|
|
301
|
+
function createPayload(data: {
|
|
302
|
+
ownerUserId?: ObjectID | undefined;
|
|
303
|
+
props?: DatabaseCommonInteractionProps | undefined;
|
|
304
|
+
columns?: Record<string, unknown> | undefined;
|
|
305
|
+
isOptOut?: boolean | undefined;
|
|
306
|
+
}): CreateBy<UserNotificationRule> {
|
|
307
|
+
const rule: UserNotificationRule = new UserNotificationRule();
|
|
308
|
+
rule.projectId = PROJECT_ID;
|
|
309
|
+
rule.ruleType = NotificationRuleType.ON_CALL_EXECUTED_INCIDENT;
|
|
310
|
+
rule.notifyAfterMinutes = 0;
|
|
311
|
+
|
|
312
|
+
if (data.ownerUserId) {
|
|
313
|
+
rule.userId = data.ownerUserId;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (data.isOptOut) {
|
|
317
|
+
rule.isOptOut = true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
Object.assign(rule, data.columns || {});
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
data: rule,
|
|
324
|
+
props: data.props || adminProps(),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function updatePayload(data: {
|
|
329
|
+
patch: Record<string, unknown>;
|
|
330
|
+
query?: Record<string, unknown> | undefined;
|
|
331
|
+
props?: DatabaseCommonInteractionProps | undefined;
|
|
332
|
+
}): UpdateBy<UserNotificationRule> {
|
|
333
|
+
return {
|
|
334
|
+
query: (data.query || {
|
|
335
|
+
_id: RULE_ID.toString(),
|
|
336
|
+
}) as unknown as UpdateBy<UserNotificationRule>["query"],
|
|
337
|
+
data: data.patch as unknown as UpdateBy<UserNotificationRule>["data"],
|
|
338
|
+
props: data.props || adminProps(),
|
|
339
|
+
limit: 10,
|
|
340
|
+
skip: 0,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function deletePayload(
|
|
345
|
+
data: {
|
|
346
|
+
query?: Record<string, unknown> | undefined;
|
|
347
|
+
props?: DatabaseCommonInteractionProps | undefined;
|
|
348
|
+
} = {},
|
|
349
|
+
): DeleteBy<UserNotificationRule> {
|
|
350
|
+
return {
|
|
351
|
+
query: (data.query || {
|
|
352
|
+
_id: RULE_ID.toString(),
|
|
353
|
+
}) as unknown as DeleteBy<UserNotificationRule>["query"],
|
|
354
|
+
props: data.props || adminProps(),
|
|
355
|
+
limit: 10,
|
|
356
|
+
skip: 0,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/* The query the guard actually read the affected rows with. */
|
|
361
|
+
function guardReadQuery(findBySpy: jest.SpyInstance): Record<string, unknown> {
|
|
362
|
+
return (
|
|
363
|
+
findBySpy.mock.calls[0]![0] as {
|
|
364
|
+
query: Record<string, unknown>;
|
|
365
|
+
}
|
|
366
|
+
).query;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/*
|
|
370
|
+
* A rule as the database has it. Deliberately built as a plain shape rather
|
|
371
|
+
* than a model instance: these stand in for rows read back out of Postgres, and
|
|
372
|
+
* making them structurally different from the payloads above keeps "which of
|
|
373
|
+
* these two did the guard actually read" an honest question.
|
|
374
|
+
*/
|
|
375
|
+
function persistedRule(
|
|
376
|
+
overrides: Record<string, unknown> = {},
|
|
377
|
+
): UserNotificationRule {
|
|
378
|
+
return {
|
|
379
|
+
id: RULE_ID,
|
|
380
|
+
_id: RULE_ID.toString(),
|
|
381
|
+
projectId: PROJECT_ID,
|
|
382
|
+
userId: VICTIM_USER_ID,
|
|
383
|
+
...overrides,
|
|
384
|
+
} as unknown as UserNotificationRule;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/*
|
|
388
|
+
* A notification-method row as its service hands it back. It carries the SAME
|
|
389
|
+
* projectId as the rule in every ownership test below, because project scoping
|
|
390
|
+
* is the check a reader instinctively assumes is already covering this - and it
|
|
391
|
+
* is not. Both rows being in one project is the normal case for this attack:
|
|
392
|
+
* the admin and their victim are colleagues.
|
|
393
|
+
*/
|
|
394
|
+
function methodRow(data: {
|
|
395
|
+
ownerUserId: ObjectID;
|
|
396
|
+
methodId: ObjectID;
|
|
397
|
+
}): Record<string, unknown> {
|
|
398
|
+
return {
|
|
399
|
+
id: data.methodId,
|
|
400
|
+
_id: data.methodId.toString(),
|
|
401
|
+
projectId: PROJECT_ID,
|
|
402
|
+
userId: data.ownerUserId,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function executeOptions(): ExecuteNotificationRuleOptions {
|
|
407
|
+
return {
|
|
408
|
+
projectId: PROJECT_ID,
|
|
409
|
+
userNotificationEventType: UserNotificationEventType.IncidentCreated,
|
|
410
|
+
triggeredByIncidentId: INCIDENT_ID,
|
|
411
|
+
onCallPolicyId: undefined,
|
|
412
|
+
userNotificationLogId: ON_CALL_LOG_ID,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/*
|
|
417
|
+
* The owner's warning email is fire-and-forget by construction: the row is
|
|
418
|
+
* already committed when it is sent, so recordAdminRuleChange attaches a
|
|
419
|
+
* .catch and deliberately does not await it. Asserting on it therefore means
|
|
420
|
+
* letting the microtask queue drain first, and a macrotask boundary is the only
|
|
421
|
+
* reliable way to do that.
|
|
422
|
+
*/
|
|
423
|
+
function flushAsync(): Promise<void> {
|
|
424
|
+
return new Promise<void>((resolve: () => void): void => {
|
|
425
|
+
setTimeout(resolve, 0);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* The shape of the log attributes recordAdminRuleChange stamps its trail with. */
|
|
430
|
+
interface AdminChangeLogAttributes {
|
|
431
|
+
projectId?: string | undefined;
|
|
432
|
+
userId?: string | undefined;
|
|
433
|
+
actorUserId?: string | undefined;
|
|
434
|
+
userNotificationRuleId?: string | undefined;
|
|
435
|
+
action?: string | undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function adminChangeLogAttributes(
|
|
439
|
+
infoSpy: jest.SpyInstance,
|
|
440
|
+
): Array<AdminChangeLogAttributes> {
|
|
441
|
+
return infoSpy.mock.calls
|
|
442
|
+
.map((call: Array<unknown>): AdminChangeLogAttributes | undefined => {
|
|
443
|
+
return call[1] as AdminChangeLogAttributes | undefined;
|
|
444
|
+
})
|
|
445
|
+
.filter((attributes: AdminChangeLogAttributes | undefined): boolean => {
|
|
446
|
+
return Boolean(attributes?.actorUserId);
|
|
447
|
+
}) as Array<AdminChangeLogAttributes>;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* A timeline row snapshotted at create() time. */
|
|
451
|
+
interface TimelineSnapshot {
|
|
452
|
+
status: UserNotificationStatus | undefined;
|
|
453
|
+
statusMessage: string | undefined;
|
|
454
|
+
userId: string | undefined;
|
|
455
|
+
userNotificationRuleId: string | undefined;
|
|
456
|
+
userNotificationLogId: string | undefined;
|
|
457
|
+
isRoot: boolean | undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
interface Stubs {
|
|
461
|
+
teamMemberFindOneBy: jest.SpyInstance;
|
|
462
|
+
ruleFindBy: jest.SpyInstance;
|
|
463
|
+
ruleFindOneById: jest.SpyInstance;
|
|
464
|
+
deliver: jest.SpyInstance;
|
|
465
|
+
claim: jest.SpyInstance;
|
|
466
|
+
timelineCreate: jest.SpyInstance;
|
|
467
|
+
sendMail: jest.SpyInstance;
|
|
468
|
+
userFindOneById: jest.SpyInstance;
|
|
469
|
+
projectFindOneById: jest.SpyInstance;
|
|
470
|
+
dashboardUrl: jest.SpyInstance;
|
|
471
|
+
auditRecordCreate: jest.SpyInstance;
|
|
472
|
+
auditRecordUpdate: jest.SpyInstance;
|
|
473
|
+
auditRecordDelete: jest.SpyInstance;
|
|
474
|
+
loggerInfo: jest.SpyInstance;
|
|
475
|
+
loggerError: jest.SpyInstance;
|
|
476
|
+
methodFindOneById: Map<string, jest.SpyInstance>;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
describe("Administrative notification rule edit guards", () => {
|
|
480
|
+
let stubs: Stubs;
|
|
481
|
+
let timelineRows: Array<TimelineSnapshot>;
|
|
482
|
+
|
|
483
|
+
beforeEach(() => {
|
|
484
|
+
timelineRows = [];
|
|
485
|
+
|
|
486
|
+
const methodFindOneById: Map<string, jest.SpyInstance> = new Map<
|
|
487
|
+
string,
|
|
488
|
+
jest.SpyInstance
|
|
489
|
+
>();
|
|
490
|
+
|
|
491
|
+
for (const channel of CHANNELS) {
|
|
492
|
+
/*
|
|
493
|
+
* Default: every method row in the world belongs to the victim, i.e. to
|
|
494
|
+
* the user the rules under test belong to. A test that cares about a
|
|
495
|
+
* mismatch has to say so for its own channel, which means a guard that
|
|
496
|
+
* consults the WRONG service's rows fails rather than passes by luck.
|
|
497
|
+
*/
|
|
498
|
+
methodFindOneById.set(
|
|
499
|
+
channel.idColumn,
|
|
500
|
+
jest.spyOn(channel.service, "findOneById").mockResolvedValue(
|
|
501
|
+
methodRow({
|
|
502
|
+
ownerUserId: VICTIM_USER_ID,
|
|
503
|
+
methodId: VICTIM_METHOD_ID,
|
|
504
|
+
}) as never,
|
|
505
|
+
),
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
stubs = {
|
|
510
|
+
teamMemberFindOneBy: jest
|
|
511
|
+
.spyOn(TeamMemberService, "findOneBy")
|
|
512
|
+
.mockResolvedValue({
|
|
513
|
+
id: TEAM_MEMBER_ID,
|
|
514
|
+
_id: TEAM_MEMBER_ID.toString(),
|
|
515
|
+
} as unknown as TeamMember as never),
|
|
516
|
+
ruleFindBy: jest
|
|
517
|
+
.spyOn(UserNotificationRuleService, "findBy")
|
|
518
|
+
.mockResolvedValue([persistedRule()] as never),
|
|
519
|
+
ruleFindOneById: jest
|
|
520
|
+
.spyOn(UserNotificationRuleService, "findOneById")
|
|
521
|
+
.mockResolvedValue(null as never),
|
|
522
|
+
deliver: jest
|
|
523
|
+
.spyOn(ruleService(), "deliverNotificationForRule")
|
|
524
|
+
.mockResolvedValue(true as never),
|
|
525
|
+
claim: jest
|
|
526
|
+
.spyOn(UserOnCallLogService, "claimNotificationRuleExecution")
|
|
527
|
+
.mockResolvedValue(true as never),
|
|
528
|
+
timelineCreate: jest
|
|
529
|
+
.spyOn(UserOnCallLogTimelineService, "create")
|
|
530
|
+
.mockImplementation(
|
|
531
|
+
(
|
|
532
|
+
createBy: CreateBy<UserOnCallLogTimeline>,
|
|
533
|
+
): Promise<UserOnCallLogTimeline> => {
|
|
534
|
+
timelineRows.push({
|
|
535
|
+
status: createBy.data.status,
|
|
536
|
+
statusMessage: createBy.data.statusMessage,
|
|
537
|
+
userId: createBy.data.userId?.toString(),
|
|
538
|
+
userNotificationRuleId:
|
|
539
|
+
createBy.data.userNotificationRuleId?.toString(),
|
|
540
|
+
userNotificationLogId:
|
|
541
|
+
createBy.data.userNotificationLogId?.toString(),
|
|
542
|
+
isRoot: createBy.props.isRoot,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
return Promise.resolve({
|
|
546
|
+
id: TIMELINE_ID,
|
|
547
|
+
_id: TIMELINE_ID.toString(),
|
|
548
|
+
} as unknown as UserOnCallLogTimeline);
|
|
549
|
+
},
|
|
550
|
+
) as unknown as jest.SpyInstance,
|
|
551
|
+
sendMail: jest
|
|
552
|
+
.spyOn(MailService, "sendMail")
|
|
553
|
+
.mockResolvedValue(undefined as never),
|
|
554
|
+
userFindOneById: jest
|
|
555
|
+
.spyOn(UserService, "findOneById")
|
|
556
|
+
.mockImplementation((findBy: { id: ObjectID }): Promise<User> => {
|
|
557
|
+
const isAdmin: boolean =
|
|
558
|
+
findBy.id.toString() === ADMIN_USER_ID.toString();
|
|
559
|
+
|
|
560
|
+
return Promise.resolve({
|
|
561
|
+
id: findBy.id,
|
|
562
|
+
_id: findBy.id.toString(),
|
|
563
|
+
name: new Name(isAdmin ? "Ada Admin" : "Vic Responder"),
|
|
564
|
+
email: new Email(isAdmin ? ADMIN_EMAIL : VICTIM_EMAIL),
|
|
565
|
+
} as unknown as User);
|
|
566
|
+
}) as unknown as jest.SpyInstance,
|
|
567
|
+
projectFindOneById: jest
|
|
568
|
+
.spyOn(ProjectService, "findOneById")
|
|
569
|
+
.mockResolvedValue({
|
|
570
|
+
id: PROJECT_ID,
|
|
571
|
+
_id: PROJECT_ID.toString(),
|
|
572
|
+
name: "Acme Ops",
|
|
573
|
+
} as unknown as Project as never),
|
|
574
|
+
dashboardUrl: jest
|
|
575
|
+
.spyOn(DatabaseConfig, "getDashboardUrl")
|
|
576
|
+
.mockResolvedValue(URL.fromString("https://oneuptime.test") as never),
|
|
577
|
+
auditRecordCreate: jest
|
|
578
|
+
.spyOn(AuditLogService, "recordCreate")
|
|
579
|
+
.mockResolvedValue(undefined as never),
|
|
580
|
+
auditRecordUpdate: jest
|
|
581
|
+
.spyOn(AuditLogService, "recordUpdate")
|
|
582
|
+
.mockResolvedValue(undefined as never),
|
|
583
|
+
auditRecordDelete: jest
|
|
584
|
+
.spyOn(AuditLogService, "recordDelete")
|
|
585
|
+
.mockResolvedValue(undefined as never),
|
|
586
|
+
loggerInfo: jest.spyOn(logger, "info").mockImplementation((): void => {
|
|
587
|
+
return undefined;
|
|
588
|
+
}),
|
|
589
|
+
loggerError: jest.spyOn(logger, "error").mockImplementation((): void => {
|
|
590
|
+
return undefined;
|
|
591
|
+
}),
|
|
592
|
+
methodFindOneById: methodFindOneById,
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
jest.spyOn(logger, "warn").mockImplementation((): void => {
|
|
596
|
+
return undefined;
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
afterEach(() => {
|
|
601
|
+
jest.restoreAllMocks();
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
/*
|
|
605
|
+
* ------------------------------------------------------------------
|
|
606
|
+
* R1 - an on-behalf-of write is confined to the acting project's roster.
|
|
607
|
+
* ------------------------------------------------------------------
|
|
608
|
+
*/
|
|
609
|
+
describe("R1: the rule's owner must be a member of the acting project", () => {
|
|
610
|
+
test("a project admin cannot write a rule for a user who is not on this project's roster", async () => {
|
|
611
|
+
/*
|
|
612
|
+
* The attack in one request. The caller is a genuine administrator of
|
|
613
|
+
* PROJECT_ID - the permission layer has already waved them through, which
|
|
614
|
+
* is precisely why this check has to exist below it - and the user id
|
|
615
|
+
* they name belongs to a project they have nothing to do with. A user id
|
|
616
|
+
* is global; an administrative permission is not.
|
|
617
|
+
*/
|
|
618
|
+
stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
|
|
619
|
+
|
|
620
|
+
await expect(
|
|
621
|
+
ruleService().onBeforeCreate(
|
|
622
|
+
createPayload({
|
|
623
|
+
ownerUserId: STRANGER_USER_ID,
|
|
624
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
625
|
+
}),
|
|
626
|
+
),
|
|
627
|
+
).rejects.toThrow(
|
|
628
|
+
`Cannot create a notification rule for user ${STRANGER_USER_ID.toString()} because they are not a member of this project.`,
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
/*
|
|
632
|
+
* And the refusal comes before any method lookup. Not a correctness
|
|
633
|
+
* requirement so much as evidence that the roster check really is the
|
|
634
|
+
* thing that rejected this, rather than some later guard tripping over
|
|
635
|
+
* the same fixture.
|
|
636
|
+
*/
|
|
637
|
+
expect(stubs.methodFindOneById.get("userEmailId")).not.toHaveBeenCalled();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("a project admin CAN write a rule for a user who is on the roster", async () => {
|
|
641
|
+
/*
|
|
642
|
+
* The other half, and the more important one to keep passing. An admin
|
|
643
|
+
* who cannot repair a colleague's notification setup is the whole problem
|
|
644
|
+
* this phase exists to solve, so a guard that refuses this case has not
|
|
645
|
+
* been made safe - it has been made useless, and the next engineer will
|
|
646
|
+
* remove it.
|
|
647
|
+
*/
|
|
648
|
+
const payload: CreateBy<UserNotificationRule> = createPayload({
|
|
649
|
+
ownerUserId: VICTIM_USER_ID,
|
|
650
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const result: OnCreate<UserNotificationRule> =
|
|
654
|
+
await ruleService().onBeforeCreate(payload);
|
|
655
|
+
|
|
656
|
+
expect(stubs.teamMemberFindOneBy).toHaveBeenCalledTimes(1);
|
|
657
|
+
// The guard validates ownership; it does not quietly re-point it.
|
|
658
|
+
expect(result.createBy.data.userId?.toString()).toBe(
|
|
659
|
+
VICTIM_USER_ID.toString(),
|
|
660
|
+
);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("membership is asked of the session's tenant, with root props, and only counts an accepted invitation", async () => {
|
|
664
|
+
await ruleService().onBeforeCreate(
|
|
665
|
+
createPayload({
|
|
666
|
+
ownerUserId: VICTIM_USER_ID,
|
|
667
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
const rosterQuery: {
|
|
672
|
+
query: {
|
|
673
|
+
userId: ObjectID;
|
|
674
|
+
projectId: ObjectID;
|
|
675
|
+
hasAcceptedInvitation: boolean;
|
|
676
|
+
};
|
|
677
|
+
props: { isRoot: boolean };
|
|
678
|
+
} = stubs.teamMemberFindOneBy.mock.calls[0]![0] as {
|
|
679
|
+
query: {
|
|
680
|
+
userId: ObjectID;
|
|
681
|
+
projectId: ObjectID;
|
|
682
|
+
hasAcceptedInvitation: boolean;
|
|
683
|
+
};
|
|
684
|
+
props: { isRoot: boolean };
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
expect(rosterQuery.query.userId.toString()).toBe(
|
|
688
|
+
VICTIM_USER_ID.toString(),
|
|
689
|
+
);
|
|
690
|
+
/*
|
|
691
|
+
* props.tenantId, never a projectId out of the body. A body-supplied
|
|
692
|
+
* project would let the caller nominate the roster they wish to be
|
|
693
|
+
* checked against, which is the same as not checking.
|
|
694
|
+
*/
|
|
695
|
+
expect(rosterQuery.query.projectId.toString()).toBe(
|
|
696
|
+
PROJECT_ID.toString(),
|
|
697
|
+
);
|
|
698
|
+
/*
|
|
699
|
+
* A pending invitation is not membership. Any admin can invite any email
|
|
700
|
+
* address, so counting an unaccepted invitation would hand back most of
|
|
701
|
+
* what this guard just took away.
|
|
702
|
+
*/
|
|
703
|
+
expect(rosterQuery.query.hasAcceptedInvitation).toBe(true);
|
|
704
|
+
/*
|
|
705
|
+
* isRoot, because this is a question about the database's state. Asked
|
|
706
|
+
* through the caller's own scope, a caller who cannot READ the roster
|
|
707
|
+
* would get "no rows" and any lenient reading of that answer opens the
|
|
708
|
+
* door this closes.
|
|
709
|
+
*/
|
|
710
|
+
expect(rosterQuery.props.isRoot).toBe(true);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("the roster consulted is the session's project, not the project named in the body", async () => {
|
|
714
|
+
/*
|
|
715
|
+
* The body says the rule lives in PROJECT_ID while the session is scoped
|
|
716
|
+
* to OTHER_PROJECT_ID. Membership must be judged where the caller is
|
|
717
|
+
* actually acting, or an admin of a throwaway project could authorise
|
|
718
|
+
* themselves by writing somebody else's project id into the payload.
|
|
719
|
+
*/
|
|
720
|
+
stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
|
|
721
|
+
|
|
722
|
+
await expect(
|
|
723
|
+
ruleService().onBeforeCreate(
|
|
724
|
+
createPayload({
|
|
725
|
+
ownerUserId: VICTIM_USER_ID,
|
|
726
|
+
props: adminProps({ tenantId: OTHER_PROJECT_ID }),
|
|
727
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
728
|
+
}),
|
|
729
|
+
),
|
|
730
|
+
).rejects.toThrow("are not a member of this project");
|
|
731
|
+
|
|
732
|
+
const rosterQuery: { query: { projectId: ObjectID } } = stubs
|
|
733
|
+
.teamMemberFindOneBy.mock.calls[0]![0] as {
|
|
734
|
+
query: { projectId: ObjectID };
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
expect(rosterQuery.query.projectId.toString()).toBe(
|
|
738
|
+
OTHER_PROJECT_ID.toString(),
|
|
739
|
+
);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("a session with no tenant at all cannot write for another user", async () => {
|
|
743
|
+
await expect(
|
|
744
|
+
ruleService().onBeforeCreate(
|
|
745
|
+
createPayload({
|
|
746
|
+
ownerUserId: VICTIM_USER_ID,
|
|
747
|
+
props: adminProps({ tenantId: undefined }),
|
|
748
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
749
|
+
}),
|
|
750
|
+
),
|
|
751
|
+
).rejects.toThrow(
|
|
752
|
+
"A project is required to create a notification rule for another user.",
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test("configuring your own rules is not an administrative act and is not roster-checked", async () => {
|
|
759
|
+
stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
|
|
760
|
+
methodRow({
|
|
761
|
+
ownerUserId: ADMIN_USER_ID,
|
|
762
|
+
methodId: ADMIN_METHOD_ID,
|
|
763
|
+
}) as never,
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
await ruleService().onBeforeCreate(
|
|
767
|
+
createPayload({
|
|
768
|
+
ownerUserId: ADMIN_USER_ID,
|
|
769
|
+
columns: { userEmailId: ADMIN_METHOD_ID },
|
|
770
|
+
}),
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("an omitted owner column is a self-write, because the ownership stamp happens after this hook", async () => {
|
|
777
|
+
/*
|
|
778
|
+
* A first-party client has no reason to echo its own user id back, so the
|
|
779
|
+
* ordinary self-service create arrives with userId unset and
|
|
780
|
+
* CreatePermission stamps it later. Reading data.userId alone here would
|
|
781
|
+
* treat every one of those as an ownerless on-behalf-of write.
|
|
782
|
+
*/
|
|
783
|
+
stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
|
|
784
|
+
methodRow({
|
|
785
|
+
ownerUserId: ADMIN_USER_ID,
|
|
786
|
+
methodId: ADMIN_METHOD_ID,
|
|
787
|
+
}) as never,
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
await ruleService().onBeforeCreate(
|
|
791
|
+
createPayload({
|
|
792
|
+
columns: { userEmailId: ADMIN_METHOD_ID },
|
|
793
|
+
}),
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
|
|
797
|
+
// ...and the method is still checked, against the actor as implied owner.
|
|
798
|
+
expect(stubs.methodFindOneById.get("userEmailId")).toHaveBeenCalled();
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("an opt-out row for another user is roster-checked too - silence is also a change to their paging", async () => {
|
|
802
|
+
/*
|
|
803
|
+
* An opt-out rule carries no method at all, so R3 has nothing to say
|
|
804
|
+
* about it. It still decides that a named human is NOT paged for a
|
|
805
|
+
* severity, which is the more dangerous half of the feature, so R1 has to
|
|
806
|
+
* cover it independently of whether a method is present.
|
|
807
|
+
*/
|
|
808
|
+
stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
|
|
809
|
+
|
|
810
|
+
await expect(
|
|
811
|
+
ruleService().onBeforeCreate(
|
|
812
|
+
createPayload({
|
|
813
|
+
ownerUserId: STRANGER_USER_ID,
|
|
814
|
+
isOptOut: true,
|
|
815
|
+
}),
|
|
816
|
+
),
|
|
817
|
+
).rejects.toThrow("are not a member of this project");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("an actor-less caller - an API key holding the permission - is still roster-checked", async () => {
|
|
821
|
+
/*
|
|
822
|
+
* An API key is authenticated but is nobody: props.userId is undefined.
|
|
823
|
+
* That makes every write it does an on-behalf-of write by definition, and
|
|
824
|
+
* it must not be able to slip past the roster because the guard could not
|
|
825
|
+
* work out who it "really" is. A key scoped to one project is still only
|
|
826
|
+
* a claim about that project.
|
|
827
|
+
*/
|
|
828
|
+
stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
|
|
829
|
+
|
|
830
|
+
await expect(
|
|
831
|
+
ruleService().onBeforeCreate(
|
|
832
|
+
createPayload({
|
|
833
|
+
ownerUserId: STRANGER_USER_ID,
|
|
834
|
+
props: { tenantId: PROJECT_ID },
|
|
835
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
836
|
+
}),
|
|
837
|
+
),
|
|
838
|
+
).rejects.toThrow("are not a member of this project");
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("root writes are exempt - internal seeding builds both halves from one user id", async () => {
|
|
842
|
+
await ruleService().onBeforeCreate(
|
|
843
|
+
createPayload({
|
|
844
|
+
ownerUserId: VICTIM_USER_ID,
|
|
845
|
+
props: { isRoot: true },
|
|
846
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
847
|
+
}),
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
|
|
851
|
+
expect(stubs.methodFindOneById.get("userEmailId")).not.toHaveBeenCalled();
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
/*
|
|
856
|
+
* ------------------------------------------------------------------
|
|
857
|
+
* R3 on create - a rule may only name methods its own owner owns.
|
|
858
|
+
* ------------------------------------------------------------------
|
|
859
|
+
*/
|
|
860
|
+
describe("R3 on create: a rule's method must belong to the rule's user", () => {
|
|
861
|
+
test("the guard covers exactly the notification method columns the model declares", () => {
|
|
862
|
+
/*
|
|
863
|
+
* Derived from the model rather than restated, so that an eighth channel
|
|
864
|
+
* added to UserNotificationRule and forgotten in the guard fails HERE,
|
|
865
|
+
* loudly, instead of quietly becoming the one unlocked door.
|
|
866
|
+
*/
|
|
867
|
+
const methodIdColumnPattern: RegExp = /^user[A-Z][A-Za-z]*Id$/;
|
|
868
|
+
|
|
869
|
+
const columnsOnModel: Array<string> = Object.keys(
|
|
870
|
+
new UserNotificationRule(),
|
|
871
|
+
).filter((column: string): boolean => {
|
|
872
|
+
return methodIdColumnPattern.test(column);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// If the derivation itself ever breaks, fail rather than pass vacuously.
|
|
876
|
+
expect(columnsOnModel).toHaveLength(7);
|
|
877
|
+
|
|
878
|
+
expect(
|
|
879
|
+
[
|
|
880
|
+
...UserNotificationRuleAdminService.getNotificationMethodIdColumns(),
|
|
881
|
+
].sort(),
|
|
882
|
+
).toEqual([...columnsOnModel].sort());
|
|
883
|
+
expect(
|
|
884
|
+
[
|
|
885
|
+
...CHANNELS.map((c: ChannelFixture): string => {
|
|
886
|
+
return c.idColumn;
|
|
887
|
+
}),
|
|
888
|
+
].sort(),
|
|
889
|
+
).toEqual([...columnsOnModel].sort());
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
/*
|
|
893
|
+
* forEach rather than a bare for-of: the per-channel tests close over the
|
|
894
|
+
* `stubs` that beforeEach rebuilds, and a function declared directly inside
|
|
895
|
+
* a loop statement capturing a reassigned binding is exactly what
|
|
896
|
+
* no-loop-func exists to catch. The callback gives each channel its own
|
|
897
|
+
* scope and says so.
|
|
898
|
+
*/
|
|
899
|
+
CHANNELS.forEach((channel: ChannelFixture): void => {
|
|
900
|
+
test(`${channel.label}: a rule owned by one user may not point at another user's ${channel.idColumn}, even inside one project`, async () => {
|
|
901
|
+
/*
|
|
902
|
+
* Both rows live in PROJECT_ID. That is the point of the fixture:
|
|
903
|
+
* project scoping is the check everyone assumes already covers this,
|
|
904
|
+
* and it does not - the admin and the responder they are hijacking are
|
|
905
|
+
* colleagues in the same project by construction.
|
|
906
|
+
*/
|
|
907
|
+
stubs.methodFindOneById.get(channel.idColumn)!.mockResolvedValue(
|
|
908
|
+
methodRow({
|
|
909
|
+
ownerUserId: ADMIN_USER_ID,
|
|
910
|
+
methodId: ADMIN_METHOD_ID,
|
|
911
|
+
}) as never,
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
await expect(
|
|
915
|
+
ruleService().onBeforeCreate(
|
|
916
|
+
createPayload({
|
|
917
|
+
ownerUserId: VICTIM_USER_ID,
|
|
918
|
+
columns: { [channel.idColumn]: ADMIN_METHOD_ID },
|
|
919
|
+
}),
|
|
920
|
+
),
|
|
921
|
+
).rejects.toThrow(
|
|
922
|
+
`The ${channel.label} notification method referenced by this rule belongs to a different user.`,
|
|
923
|
+
);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test(`${channel.label}: the ${channel.relationColumn} relation slot is the same write and is refused the same way`, async () => {
|
|
927
|
+
/*
|
|
928
|
+
* The REST layer accepts a relation object as well as a bare FK, so a
|
|
929
|
+
* guard that only reads the id column is bypassed by sending the other
|
|
930
|
+
* spelling of the identical write.
|
|
931
|
+
*/
|
|
932
|
+
stubs.methodFindOneById.get(channel.idColumn)!.mockResolvedValue(
|
|
933
|
+
methodRow({
|
|
934
|
+
ownerUserId: ADMIN_USER_ID,
|
|
935
|
+
methodId: ADMIN_METHOD_ID,
|
|
936
|
+
}) as never,
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
await expect(
|
|
940
|
+
ruleService().onBeforeCreate(
|
|
941
|
+
createPayload({
|
|
942
|
+
ownerUserId: VICTIM_USER_ID,
|
|
943
|
+
columns: {
|
|
944
|
+
[channel.relationColumn]: {
|
|
945
|
+
_id: ADMIN_METHOD_ID.toString(),
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
}),
|
|
949
|
+
),
|
|
950
|
+
).rejects.toThrow(
|
|
951
|
+
`The ${channel.label} notification method referenced by this rule belongs to a different user.`,
|
|
952
|
+
);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
test(`${channel.label}: a rule pointing at its own owner's ${channel.idColumn} is written`, async () => {
|
|
956
|
+
await ruleService().onBeforeCreate(
|
|
957
|
+
createPayload({
|
|
958
|
+
ownerUserId: VICTIM_USER_ID,
|
|
959
|
+
columns: { [channel.idColumn]: VICTIM_METHOD_ID },
|
|
960
|
+
}),
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
const lookedUpId: ObjectID = stubs.methodFindOneById.get(
|
|
964
|
+
channel.idColumn,
|
|
965
|
+
)!.mock.calls[0]![0].id as ObjectID;
|
|
966
|
+
|
|
967
|
+
expect(lookedUpId.toString()).toBe(VICTIM_METHOD_ID.toString());
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test("a method id that matches no row is refused rather than waved through", async () => {
|
|
972
|
+
/*
|
|
973
|
+
* "Not found" and "not yours" are the same answer here - in both cases
|
|
974
|
+
* the caller named a row they have no business naming - and a guard whose
|
|
975
|
+
* unknown case resolves to `undefined === undefined` opens on exactly the
|
|
976
|
+
* input it was written to reject.
|
|
977
|
+
*/
|
|
978
|
+
stubs.methodFindOneById
|
|
979
|
+
.get("userWebhookId")!
|
|
980
|
+
.mockResolvedValue(null as never);
|
|
981
|
+
|
|
982
|
+
await expect(
|
|
983
|
+
ruleService().onBeforeCreate(
|
|
984
|
+
createPayload({
|
|
985
|
+
ownerUserId: VICTIM_USER_ID,
|
|
986
|
+
columns: { userWebhookId: ADMIN_METHOD_ID },
|
|
987
|
+
}),
|
|
988
|
+
),
|
|
989
|
+
).rejects.toThrow(
|
|
990
|
+
"The Webhook notification method referenced by this rule does not exist.",
|
|
991
|
+
);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("the method's owner is read with root props, so the caller's own visibility cannot shape the answer", async () => {
|
|
995
|
+
await ruleService().onBeforeCreate(
|
|
996
|
+
createPayload({
|
|
997
|
+
ownerUserId: VICTIM_USER_ID,
|
|
998
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
999
|
+
}),
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
const lookup: { props: { isRoot: boolean } } =
|
|
1003
|
+
stubs.methodFindOneById.get("userEmailId")!.mock.calls[0]![0] as {
|
|
1004
|
+
props: { isRoot: boolean };
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
expect(lookup.props.isRoot).toBe(true);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test("the mirror hijack is refused too: my own rule may not deliver to your address", async () => {
|
|
1011
|
+
/*
|
|
1012
|
+
* userId = me, userEmailId = yours. It steals nobody's pages; it COPIES
|
|
1013
|
+
* mine to an address I do not own, which is how you page-bomb a colleague
|
|
1014
|
+
* or exfiltrate incident titles. It was writable long before this phase
|
|
1015
|
+
* widened anything, and it is the same comparison, so it is refused by
|
|
1016
|
+
* the same guard.
|
|
1017
|
+
*/
|
|
1018
|
+
stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
|
|
1019
|
+
methodRow({
|
|
1020
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1021
|
+
methodId: VICTIM_METHOD_ID,
|
|
1022
|
+
}) as never,
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
await expect(
|
|
1026
|
+
ruleService().onBeforeCreate(
|
|
1027
|
+
createPayload({
|
|
1028
|
+
ownerUserId: ADMIN_USER_ID,
|
|
1029
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
1030
|
+
}),
|
|
1031
|
+
),
|
|
1032
|
+
).rejects.toThrow(
|
|
1033
|
+
"The Email notification method referenced by this rule belongs to a different user.",
|
|
1034
|
+
);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("naming one method in the FK column and a DIFFERENT one in the relation slot does not smuggle the second one past the guard", async () => {
|
|
1038
|
+
/*
|
|
1039
|
+
* KNOWN FAILURE - a real bypass of R3, not a test artefact. Read this
|
|
1040
|
+
* before "fixing" the test.
|
|
1041
|
+
*
|
|
1042
|
+
* `userEmailId` and `userEmail` are two spellings of ONE database column,
|
|
1043
|
+
* and a request may carry both. collectNotificationMethodReferences
|
|
1044
|
+
* resolves them with `carrier[idColumn] || carrier[relationColumn]`, so
|
|
1045
|
+
* when both are present it validates the FK COLUMN and ignores the
|
|
1046
|
+
* relation.
|
|
1047
|
+
*
|
|
1048
|
+
* TypeORM resolves the same conflict the other way round. The declared
|
|
1049
|
+
* @Column("userEmailId") and the @JoinColumn of the userEmail relation
|
|
1050
|
+
* share one ColumnMetadata (RelationJoinColumnBuilder attaches
|
|
1051
|
+
* relationMetadata to the column the model already declares), and
|
|
1052
|
+
* ColumnMetadata.getEntityValue reads the RELATION first, falling back to
|
|
1053
|
+
* the scalar property only when the relation slot holds no object.
|
|
1054
|
+
*
|
|
1055
|
+
* So the id that is validated and the id that is written are different
|
|
1056
|
+
* ids, and the caller chooses both. Send the rule owner's own method in
|
|
1057
|
+
* userEmailId to satisfy the guard, and your own row in userEmail to be
|
|
1058
|
+
* persisted. Same for update: a patch carrying a relation object takes
|
|
1059
|
+
* DatabaseService's `hasRelationUpdates` branch and is written with
|
|
1060
|
+
* save(), which uses the same precedence.
|
|
1061
|
+
*
|
|
1062
|
+
* The delivery-time check in executeNotificationRuleItem is what stops
|
|
1063
|
+
* this being a live page-redirection - it compares the LOADED relation's
|
|
1064
|
+
* owner and refuses - so the blast radius today is a rule that silently
|
|
1065
|
+
* fails to page rather than one that pages the wrong person. That is a
|
|
1066
|
+
* backstop, not the guard, and R3 is the guard.
|
|
1067
|
+
*
|
|
1068
|
+
* The fix is to validate every method reference the payload carries
|
|
1069
|
+
* rather than the first one found: collect both spellings, and refuse a
|
|
1070
|
+
* payload whose two spellings disagree.
|
|
1071
|
+
*/
|
|
1072
|
+
stubs.methodFindOneById
|
|
1073
|
+
.get("userEmailId")!
|
|
1074
|
+
.mockImplementation(
|
|
1075
|
+
(findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
|
|
1076
|
+
return Promise.resolve(
|
|
1077
|
+
methodRow({
|
|
1078
|
+
ownerUserId:
|
|
1079
|
+
findBy.id.toString() === ADMIN_METHOD_ID.toString()
|
|
1080
|
+
? ADMIN_USER_ID
|
|
1081
|
+
: VICTIM_USER_ID,
|
|
1082
|
+
methodId: findBy.id,
|
|
1083
|
+
}),
|
|
1084
|
+
);
|
|
1085
|
+
},
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
await expect(
|
|
1089
|
+
ruleService().onBeforeCreate(
|
|
1090
|
+
createPayload({
|
|
1091
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1092
|
+
columns: {
|
|
1093
|
+
// Innocent, owned by the rule's owner, and the only one checked.
|
|
1094
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1095
|
+
// The attacker's own row, and the one that reaches the database.
|
|
1096
|
+
userEmail: { _id: ADMIN_METHOD_ID.toString() },
|
|
1097
|
+
},
|
|
1098
|
+
}),
|
|
1099
|
+
),
|
|
1100
|
+
).rejects.toThrow(
|
|
1101
|
+
"The Email notification method referenced by this rule belongs to a different user.",
|
|
1102
|
+
);
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test("an actor-less caller gets no benefit of the doubt about method ownership either", async () => {
|
|
1106
|
+
/*
|
|
1107
|
+
* The companion to the roster test above. An API key has no user id to
|
|
1108
|
+
* compare a method's owner against, so a guard that resolved the rule's
|
|
1109
|
+
* owner from the SESSION rather than from the payload would have nothing
|
|
1110
|
+
* to check and would let this through.
|
|
1111
|
+
*/
|
|
1112
|
+
stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
|
|
1113
|
+
methodRow({
|
|
1114
|
+
ownerUserId: ADMIN_USER_ID,
|
|
1115
|
+
methodId: ADMIN_METHOD_ID,
|
|
1116
|
+
}) as never,
|
|
1117
|
+
);
|
|
1118
|
+
|
|
1119
|
+
await expect(
|
|
1120
|
+
ruleService().onBeforeCreate(
|
|
1121
|
+
createPayload({
|
|
1122
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1123
|
+
props: { tenantId: PROJECT_ID },
|
|
1124
|
+
columns: { userEmailId: ADMIN_METHOD_ID },
|
|
1125
|
+
}),
|
|
1126
|
+
),
|
|
1127
|
+
).rejects.toThrow(
|
|
1128
|
+
"The Email notification method referenced by this rule belongs to a different user.",
|
|
1129
|
+
);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
test("a rule naming several methods is refused when any single one of them is foreign", async () => {
|
|
1133
|
+
stubs.methodFindOneById.get("userPushId")!.mockResolvedValue(
|
|
1134
|
+
methodRow({
|
|
1135
|
+
ownerUserId: ADMIN_USER_ID,
|
|
1136
|
+
methodId: ADMIN_METHOD_ID,
|
|
1137
|
+
}) as never,
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
await expect(
|
|
1141
|
+
ruleService().onBeforeCreate(
|
|
1142
|
+
createPayload({
|
|
1143
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1144
|
+
columns: {
|
|
1145
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1146
|
+
userSmsId: VICTIM_METHOD_ID,
|
|
1147
|
+
userPushId: ADMIN_METHOD_ID,
|
|
1148
|
+
},
|
|
1149
|
+
}),
|
|
1150
|
+
),
|
|
1151
|
+
).rejects.toThrow(
|
|
1152
|
+
"The Push notification method referenced by this rule belongs to a different user.",
|
|
1153
|
+
);
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
/*
|
|
1158
|
+
* ------------------------------------------------------------------
|
|
1159
|
+
* R3 on update - the owner is whatever the DATABASE says it is.
|
|
1160
|
+
* ------------------------------------------------------------------
|
|
1161
|
+
*/
|
|
1162
|
+
describe("R3 on update: the rule's owner is re-read from the database", () => {
|
|
1163
|
+
test("an admin repointing a victim's rule at the admin's own webhook is refused", async () => {
|
|
1164
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1165
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
1166
|
+
] as never);
|
|
1167
|
+
stubs.methodFindOneById.get("userWebhookId")!.mockResolvedValue(
|
|
1168
|
+
methodRow({
|
|
1169
|
+
ownerUserId: ADMIN_USER_ID,
|
|
1170
|
+
methodId: ADMIN_METHOD_ID,
|
|
1171
|
+
}) as never,
|
|
1172
|
+
);
|
|
1173
|
+
|
|
1174
|
+
await expect(
|
|
1175
|
+
ruleService().onBeforeUpdate(
|
|
1176
|
+
updatePayload({ patch: { userWebhookId: ADMIN_METHOD_ID } }),
|
|
1177
|
+
),
|
|
1178
|
+
).rejects.toThrow(
|
|
1179
|
+
"The Webhook notification method referenced by this rule belongs to a different user.",
|
|
1180
|
+
);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("a body that CLAIMS the rule belongs to the caller does not change the answer", async () => {
|
|
1184
|
+
/*
|
|
1185
|
+
* The load-bearing test of the update path. The caller writes the body,
|
|
1186
|
+
* so a userId in it is a claim made by exactly the party the guard exists
|
|
1187
|
+
* to doubt: "this row is mine, so pointing it at my webhook is fine". The
|
|
1188
|
+
* database says otherwise, and the database is what decides whose pages
|
|
1189
|
+
* select the row.
|
|
1190
|
+
*/
|
|
1191
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1192
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
1193
|
+
] as never);
|
|
1194
|
+
stubs.methodFindOneById.get("userWebhookId")!.mockResolvedValue(
|
|
1195
|
+
methodRow({
|
|
1196
|
+
ownerUserId: ADMIN_USER_ID,
|
|
1197
|
+
methodId: ADMIN_METHOD_ID,
|
|
1198
|
+
}) as never,
|
|
1199
|
+
);
|
|
1200
|
+
|
|
1201
|
+
await expect(
|
|
1202
|
+
ruleService().onBeforeUpdate(
|
|
1203
|
+
updatePayload({
|
|
1204
|
+
patch: {
|
|
1205
|
+
userId: ADMIN_USER_ID,
|
|
1206
|
+
userWebhookId: ADMIN_METHOD_ID,
|
|
1207
|
+
},
|
|
1208
|
+
}),
|
|
1209
|
+
),
|
|
1210
|
+
).rejects.toThrow(
|
|
1211
|
+
"The Webhook notification method referenced by this rule belongs to a different user.",
|
|
1212
|
+
);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
test("the database's answer is authoritative in BOTH directions, so the guard is not merely refusing every mismatched body", async () => {
|
|
1216
|
+
/*
|
|
1217
|
+
* The converse of the test above, and the one that proves the mechanism
|
|
1218
|
+
* rather than the outcome. Here the row really IS the caller's own and
|
|
1219
|
+
* the body lies in the other direction, naming the victim. A guard that
|
|
1220
|
+
* compared the body's userId against the method's owner would refuse this
|
|
1221
|
+
* legitimate self-edit; one that re-reads the row allows it.
|
|
1222
|
+
*/
|
|
1223
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1224
|
+
persistedRule({ userId: ADMIN_USER_ID }),
|
|
1225
|
+
] as never);
|
|
1226
|
+
stubs.methodFindOneById.get("userWebhookId")!.mockResolvedValue(
|
|
1227
|
+
methodRow({
|
|
1228
|
+
ownerUserId: ADMIN_USER_ID,
|
|
1229
|
+
methodId: ADMIN_METHOD_ID,
|
|
1230
|
+
}) as never,
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
const result: OnUpdate<UserNotificationRule> =
|
|
1234
|
+
await ruleService().onBeforeUpdate(
|
|
1235
|
+
updatePayload({
|
|
1236
|
+
patch: {
|
|
1237
|
+
userId: VICTIM_USER_ID,
|
|
1238
|
+
userWebhookId: ADMIN_METHOD_ID,
|
|
1239
|
+
},
|
|
1240
|
+
}),
|
|
1241
|
+
);
|
|
1242
|
+
|
|
1243
|
+
expect(result.carryForward.affectedRules).toHaveLength(1);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
test("an admin repointing a victim's rule at the VICTIM's own method is allowed - this is the repair the phase exists for", async () => {
|
|
1247
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1248
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
1249
|
+
] as never);
|
|
1250
|
+
stubs.methodFindOneById.get("userSmsId")!.mockResolvedValue(
|
|
1251
|
+
methodRow({
|
|
1252
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1253
|
+
methodId: VICTIM_METHOD_ID,
|
|
1254
|
+
}) as never,
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
const result: OnUpdate<UserNotificationRule> =
|
|
1258
|
+
await ruleService().onBeforeUpdate(
|
|
1259
|
+
updatePayload({ patch: { userSmsId: VICTIM_METHOD_ID } }),
|
|
1260
|
+
);
|
|
1261
|
+
|
|
1262
|
+
expect(result.updateBy.data).toEqual({ userSmsId: VICTIM_METHOD_ID });
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
test("the relation slot is checked on update as well as the FK column", async () => {
|
|
1266
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1267
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
1268
|
+
] as never);
|
|
1269
|
+
stubs.methodFindOneById.get("userTelegramId")!.mockResolvedValue(
|
|
1270
|
+
methodRow({
|
|
1271
|
+
ownerUserId: ADMIN_USER_ID,
|
|
1272
|
+
methodId: ADMIN_METHOD_ID,
|
|
1273
|
+
}) as never,
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
await expect(
|
|
1277
|
+
ruleService().onBeforeUpdate(
|
|
1278
|
+
updatePayload({
|
|
1279
|
+
patch: {
|
|
1280
|
+
userTelegram: { _id: ADMIN_METHOD_ID.toString() },
|
|
1281
|
+
},
|
|
1282
|
+
}),
|
|
1283
|
+
),
|
|
1284
|
+
).rejects.toThrow(
|
|
1285
|
+
"The Telegram notification method referenced by this rule belongs to a different user.",
|
|
1286
|
+
);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
test("the affected rows are read with root props, so a caller who cannot see a row cannot edit it unchecked", async () => {
|
|
1290
|
+
/*
|
|
1291
|
+
* Scoping this read to the caller's READ permission would be the subtlest
|
|
1292
|
+
* possible hole: a caller who cannot read the row gets an empty result,
|
|
1293
|
+
* the validation loop has nothing to iterate, and the write sails through
|
|
1294
|
+
* unvalidated. Read permission and write permission are different lists,
|
|
1295
|
+
* and it is the write one this hook answers for.
|
|
1296
|
+
*
|
|
1297
|
+
* Note the division of labour with the test below: root props remove the
|
|
1298
|
+
* caller's VISIBILITY from the answer, and the query narrowing removes
|
|
1299
|
+
* their lack of ENTITLEMENT from it. Neither substitutes for the other.
|
|
1300
|
+
*/
|
|
1301
|
+
await ruleService().onBeforeUpdate(
|
|
1302
|
+
updatePayload({ patch: { userEmailId: VICTIM_METHOD_ID } }),
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
const findByArgs: {
|
|
1306
|
+
query: Record<string, unknown>;
|
|
1307
|
+
props: { isRoot: boolean; ignoreHooks: boolean };
|
|
1308
|
+
} = stubs.ruleFindBy.mock.calls[0]![0] as {
|
|
1309
|
+
query: Record<string, unknown>;
|
|
1310
|
+
props: { isRoot: boolean; ignoreHooks: boolean };
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1313
|
+
expect(findByArgs.props.isRoot).toBe(true);
|
|
1314
|
+
expect(findByArgs.query["_id"]).toBe(RULE_ID.toString());
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
test("every distinct owner in a bulk update is validated, not just the first", async () => {
|
|
1318
|
+
/*
|
|
1319
|
+
* A query that matches rules belonging to two people is one request that
|
|
1320
|
+
* changes two people's paging. Validating only the first row would make
|
|
1321
|
+
* "match my own rule plus yours" a one-line bypass.
|
|
1322
|
+
*/
|
|
1323
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1324
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
1325
|
+
persistedRule({
|
|
1326
|
+
id: SECOND_RULE_ID,
|
|
1327
|
+
_id: SECOND_RULE_ID.toString(),
|
|
1328
|
+
userId: STRANGER_USER_ID,
|
|
1329
|
+
}),
|
|
1330
|
+
] as never);
|
|
1331
|
+
stubs.methodFindOneById.get("userCallId")!.mockResolvedValue(
|
|
1332
|
+
methodRow({
|
|
1333
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1334
|
+
methodId: VICTIM_METHOD_ID,
|
|
1335
|
+
}) as never,
|
|
1336
|
+
);
|
|
1337
|
+
|
|
1338
|
+
await expect(
|
|
1339
|
+
ruleService().onBeforeUpdate(
|
|
1340
|
+
updatePayload({ patch: { userCallId: VICTIM_METHOD_ID } }),
|
|
1341
|
+
),
|
|
1342
|
+
).rejects.toThrow(
|
|
1343
|
+
"The Call notification method referenced by this rule belongs to a different user.",
|
|
1344
|
+
);
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
test("a patch carrying both spellings of one method column is refused when they disagree", async () => {
|
|
1348
|
+
/*
|
|
1349
|
+
* KNOWN FAILURE, and the same bypass as its create-path twin above - see
|
|
1350
|
+
* the long note there for the ORM precedence that makes the unvalidated
|
|
1351
|
+
* spelling the one that is written.
|
|
1352
|
+
*
|
|
1353
|
+
* It is repeated here because update is the verb an admin actually uses
|
|
1354
|
+
* to "repair" a colleague's rules, and because the update path reaches
|
|
1355
|
+
* the same precedence by a different road: an Entity (many-to-one)
|
|
1356
|
+
* column does not take DatabaseService's save() branch - only
|
|
1357
|
+
* EntityArray columns do - so this goes through repository.update(),
|
|
1358
|
+
* where UpdateQueryBuilder.createUpdateExpression maps both keys onto
|
|
1359
|
+
* the one shared ColumnMetadata, dedupes them, and computes the value
|
|
1360
|
+
* with that same relation-first getEntityValue.
|
|
1361
|
+
*/
|
|
1362
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1363
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
1364
|
+
] as never);
|
|
1365
|
+
stubs.methodFindOneById
|
|
1366
|
+
.get("userWebhookId")!
|
|
1367
|
+
.mockImplementation(
|
|
1368
|
+
(findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
|
|
1369
|
+
return Promise.resolve(
|
|
1370
|
+
methodRow({
|
|
1371
|
+
ownerUserId:
|
|
1372
|
+
findBy.id.toString() === ADMIN_METHOD_ID.toString()
|
|
1373
|
+
? ADMIN_USER_ID
|
|
1374
|
+
: VICTIM_USER_ID,
|
|
1375
|
+
methodId: findBy.id,
|
|
1376
|
+
}),
|
|
1377
|
+
);
|
|
1378
|
+
},
|
|
1379
|
+
);
|
|
1380
|
+
|
|
1381
|
+
await expect(
|
|
1382
|
+
ruleService().onBeforeUpdate(
|
|
1383
|
+
updatePayload({
|
|
1384
|
+
patch: {
|
|
1385
|
+
userWebhookId: VICTIM_METHOD_ID,
|
|
1386
|
+
userWebhook: { _id: ADMIN_METHOD_ID.toString() },
|
|
1387
|
+
},
|
|
1388
|
+
}),
|
|
1389
|
+
),
|
|
1390
|
+
).rejects.toThrow(
|
|
1391
|
+
"The Webhook notification method referenced by this rule belongs to a different user.",
|
|
1392
|
+
);
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
test("an actor-less update is validated too - the guard is not gated on there being a user to blame", async () => {
|
|
1396
|
+
/*
|
|
1397
|
+
* The audit half of this phase legitimately gives up when there is no
|
|
1398
|
+
* actor to name, and it would be an easy mistake to give up on the
|
|
1399
|
+
* SECURITY half in the same breath. An API key repointing somebody's rule
|
|
1400
|
+
* at a foreign method is the same hijack with a less attributable caller,
|
|
1401
|
+
* which makes it worse rather than exempt.
|
|
1402
|
+
*/
|
|
1403
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1404
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
1405
|
+
] as never);
|
|
1406
|
+
stubs.methodFindOneById.get("userWebhookId")!.mockResolvedValue(
|
|
1407
|
+
methodRow({
|
|
1408
|
+
ownerUserId: ADMIN_USER_ID,
|
|
1409
|
+
methodId: ADMIN_METHOD_ID,
|
|
1410
|
+
}) as never,
|
|
1411
|
+
);
|
|
1412
|
+
|
|
1413
|
+
await expect(
|
|
1414
|
+
ruleService().onBeforeUpdate(
|
|
1415
|
+
updatePayload({
|
|
1416
|
+
patch: { userWebhookId: ADMIN_METHOD_ID },
|
|
1417
|
+
props: { tenantId: PROJECT_ID },
|
|
1418
|
+
}),
|
|
1419
|
+
),
|
|
1420
|
+
).rejects.toThrow(
|
|
1421
|
+
"The Webhook notification method referenced by this rule belongs to a different user.",
|
|
1422
|
+
);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
test("an update that names no method at all does no ownership lookups", async () => {
|
|
1426
|
+
await ruleService().onBeforeUpdate(
|
|
1427
|
+
updatePayload({ patch: { notifyAfterMinutes: 15 } }),
|
|
1428
|
+
);
|
|
1429
|
+
|
|
1430
|
+
for (const channel of CHANNELS) {
|
|
1431
|
+
expect(
|
|
1432
|
+
stubs.methodFindOneById.get(channel.idColumn),
|
|
1433
|
+
).not.toHaveBeenCalled();
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
/*
|
|
1439
|
+
* ------------------------------------------------------------------
|
|
1440
|
+
* The OWNERSHIP column has the same two spellings as every method column,
|
|
1441
|
+
* and it is the more dangerous of the two to get wrong: a method read from
|
|
1442
|
+
* the wrong spelling sends a page to the wrong address, while an owner read
|
|
1443
|
+
* from the wrong spelling means every check in this file - the roster check,
|
|
1444
|
+
* the method-ownership check, the audit line - was answered about a person
|
|
1445
|
+
* the row does not belong to.
|
|
1446
|
+
* ------------------------------------------------------------------
|
|
1447
|
+
*/
|
|
1448
|
+
describe("the rule's owner may be spelled once, not twice", () => {
|
|
1449
|
+
test("an owner named ONLY in the user relation is the owner the roster check is asked about", async () => {
|
|
1450
|
+
/*
|
|
1451
|
+
* THE BYPASS THIS CLOSES, in one request.
|
|
1452
|
+
*
|
|
1453
|
+
* `userId` and `user` are two decorated members over one join column.
|
|
1454
|
+
* The guard used to resolve the owner as `data.userId || props.userId`
|
|
1455
|
+
* and never look at the relation, so a payload that spells the owner
|
|
1456
|
+
* ONLY as `user: { _id: <stranger> }` left the scalar empty, fell
|
|
1457
|
+
* through to the ACTOR, and was validated as an ordinary self-write -
|
|
1458
|
+
* no roster check, and the method check run against the wrong person -
|
|
1459
|
+
* while TypeORM wrote the relation's id and the row belonged to the
|
|
1460
|
+
* stranger.
|
|
1461
|
+
*/
|
|
1462
|
+
stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
|
|
1463
|
+
|
|
1464
|
+
await expect(
|
|
1465
|
+
ruleService().onBeforeCreate(
|
|
1466
|
+
createPayload({
|
|
1467
|
+
columns: {
|
|
1468
|
+
user: { _id: STRANGER_USER_ID.toString() },
|
|
1469
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1470
|
+
},
|
|
1471
|
+
}),
|
|
1472
|
+
),
|
|
1473
|
+
).rejects.toThrow(
|
|
1474
|
+
`Cannot create a notification rule for user ${STRANGER_USER_ID.toString()} because they are not a member of this project.`,
|
|
1475
|
+
);
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
test("an owner named only in the relation is also the owner R3 compares methods against", async () => {
|
|
1479
|
+
/*
|
|
1480
|
+
* The other half of the same bypass. With the owner resolved from the
|
|
1481
|
+
* actor rather than from the relation, "userId = the victim, email = my
|
|
1482
|
+
* own" reads as a perfectly ordinary self-write and the anti-hijack
|
|
1483
|
+
* check has nothing to object to.
|
|
1484
|
+
*/
|
|
1485
|
+
stubs.methodFindOneById.get("userEmailId")!.mockResolvedValue(
|
|
1486
|
+
methodRow({
|
|
1487
|
+
ownerUserId: ADMIN_USER_ID,
|
|
1488
|
+
methodId: ADMIN_METHOD_ID,
|
|
1489
|
+
}) as never,
|
|
1490
|
+
);
|
|
1491
|
+
|
|
1492
|
+
await expect(
|
|
1493
|
+
ruleService().onBeforeCreate(
|
|
1494
|
+
createPayload({
|
|
1495
|
+
columns: {
|
|
1496
|
+
user: { _id: VICTIM_USER_ID.toString() },
|
|
1497
|
+
userEmailId: ADMIN_METHOD_ID,
|
|
1498
|
+
},
|
|
1499
|
+
}),
|
|
1500
|
+
),
|
|
1501
|
+
).rejects.toThrow(
|
|
1502
|
+
"The Email notification method referenced by this rule belongs to a different user.",
|
|
1503
|
+
);
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
test("a hydrated User entity in the relation slot is read the same way as a bare _id", async () => {
|
|
1507
|
+
/*
|
|
1508
|
+
* DatabaseService.sanitizeCreateOrUpdate turns the relation slot into a
|
|
1509
|
+
* real model instance before the write, so the guard must not be reading
|
|
1510
|
+
* a plain-object shape only. ObjectID lives on `id`; `_id` is the string
|
|
1511
|
+
* beneath it, and resolveReferenceId has to find the owner through
|
|
1512
|
+
* either.
|
|
1513
|
+
*/
|
|
1514
|
+
const owner: User = new User();
|
|
1515
|
+
owner.id = STRANGER_USER_ID;
|
|
1516
|
+
|
|
1517
|
+
stubs.teamMemberFindOneBy.mockResolvedValue(null as never);
|
|
1518
|
+
|
|
1519
|
+
await expect(
|
|
1520
|
+
ruleService().onBeforeCreate(
|
|
1521
|
+
createPayload({
|
|
1522
|
+
columns: {
|
|
1523
|
+
user: owner,
|
|
1524
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1525
|
+
},
|
|
1526
|
+
}),
|
|
1527
|
+
),
|
|
1528
|
+
).rejects.toThrow(
|
|
1529
|
+
`Cannot create a notification rule for user ${STRANGER_USER_ID.toString()} because they are not a member of this project.`,
|
|
1530
|
+
);
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
test("two spellings naming two different users are refused rather than silently reconciled", async () => {
|
|
1534
|
+
/*
|
|
1535
|
+
* Neither half of the guard can catch this on its own: the scalar names
|
|
1536
|
+
* a roster member whose own method is being pointed at, so the roster
|
|
1537
|
+
* check and the method check both pass on the value they were shown.
|
|
1538
|
+
* Which user the row ends up belonging to would be decided by a TypeORM
|
|
1539
|
+
* precedence rule that no reader of this codebase should have to know
|
|
1540
|
+
* and that an upgrade is free to change - so the contradiction is
|
|
1541
|
+
* refused in its own right, and the correctness of the guards behind it
|
|
1542
|
+
* never rests on our reading of that rule.
|
|
1543
|
+
*/
|
|
1544
|
+
await expect(
|
|
1545
|
+
ruleService().onBeforeCreate(
|
|
1546
|
+
createPayload({
|
|
1547
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1548
|
+
columns: {
|
|
1549
|
+
user: { _id: STRANGER_USER_ID.toString() },
|
|
1550
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1551
|
+
},
|
|
1552
|
+
}),
|
|
1553
|
+
),
|
|
1554
|
+
).rejects.toThrow(
|
|
1555
|
+
`This notification rule names two different users: userId says ${VICTIM_USER_ID.toString()} and user says ${STRANGER_USER_ID.toString()}.`,
|
|
1556
|
+
);
|
|
1557
|
+
|
|
1558
|
+
// And it is refused before anything is looked up on the strength of it.
|
|
1559
|
+
expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
|
|
1560
|
+
expect(stubs.methodFindOneById.get("userEmailId")).not.toHaveBeenCalled();
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
test("the same user in both spellings is not a contradiction and is accepted", async () => {
|
|
1564
|
+
await expect(
|
|
1565
|
+
ruleService().onBeforeCreate(
|
|
1566
|
+
createPayload({
|
|
1567
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1568
|
+
columns: {
|
|
1569
|
+
user: { _id: VICTIM_USER_ID.toString() },
|
|
1570
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1571
|
+
},
|
|
1572
|
+
}),
|
|
1573
|
+
),
|
|
1574
|
+
).resolves.toBeDefined();
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
test("on create the user relation is folded into userId, so the ORM is left with nothing to choose between", async () => {
|
|
1578
|
+
/*
|
|
1579
|
+
* The same discipline as CreatePermission.checkCreateOwnership, which
|
|
1580
|
+
* validates the `user` relation identically to the `userId` scalar and
|
|
1581
|
+
* then clears it. What CreatePermission cannot do is cover the
|
|
1582
|
+
* administrator - it returns early for a caller holding a real role
|
|
1583
|
+
* permission in the model's create list, which is exactly the caller
|
|
1584
|
+
* this phase introduced - so the fold has to happen here as well, before
|
|
1585
|
+
* anything downstream has to re-derive which of two sources of truth
|
|
1586
|
+
* actually reaches the database.
|
|
1587
|
+
*/
|
|
1588
|
+
const payload: CreateBy<UserNotificationRule> = createPayload({
|
|
1589
|
+
props: projectAdminProps(),
|
|
1590
|
+
columns: {
|
|
1591
|
+
user: { _id: VICTIM_USER_ID.toString() },
|
|
1592
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1593
|
+
},
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
await ruleService().onBeforeCreate(payload);
|
|
1597
|
+
|
|
1598
|
+
expect(payload.data.userId?.toString()).toBe(VICTIM_USER_ID.toString());
|
|
1599
|
+
expect(payload.data.user).toBeUndefined();
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
test("a relation slot carrying no id is the NULL the ORM would write, and the scalar stops being credited", async () => {
|
|
1603
|
+
/*
|
|
1604
|
+
* `user: {}` is an object, so TypeORM prefers it over the scalar and
|
|
1605
|
+
* writes NULL into the join column. A guard that kept reading the scalar
|
|
1606
|
+
* would validate the whole row against a user who does not own it - here
|
|
1607
|
+
* it would happily accept the victim's email address onto a row that
|
|
1608
|
+
* lands unowned. Folding reproduces the ORM's answer, the owner falls
|
|
1609
|
+
* back to the actor, and the method check sees the mismatch for what it
|
|
1610
|
+
* is.
|
|
1611
|
+
*/
|
|
1612
|
+
const payload: CreateBy<UserNotificationRule> = createPayload({
|
|
1613
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1614
|
+
columns: {
|
|
1615
|
+
user: {},
|
|
1616
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1617
|
+
},
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
await expect(ruleService().onBeforeCreate(payload)).rejects.toThrow(
|
|
1621
|
+
"The Email notification method referenced by this rule belongs to a different user.",
|
|
1622
|
+
);
|
|
1623
|
+
|
|
1624
|
+
expect(payload.data.userId).toBeUndefined();
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
test("root writes are reduced too - an ambiguous row is ambiguous whoever wrote it", async () => {
|
|
1628
|
+
/*
|
|
1629
|
+
* The reduction is a statement about the payload, not a permission
|
|
1630
|
+
* decision, so it sits in front of the root short-circuit that exempts
|
|
1631
|
+
* internal writes from R1 and R3. No seeder sends both spellings; if one
|
|
1632
|
+
* ever starts, it should find out here rather than in the delivery log.
|
|
1633
|
+
*/
|
|
1634
|
+
const payload: CreateBy<UserNotificationRule> = createPayload({
|
|
1635
|
+
props: { isRoot: true },
|
|
1636
|
+
columns: {
|
|
1637
|
+
user: { _id: VICTIM_USER_ID.toString() },
|
|
1638
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1639
|
+
},
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
await ruleService().onBeforeCreate(payload);
|
|
1643
|
+
|
|
1644
|
+
expect(payload.data.userId?.toString()).toBe(VICTIM_USER_ID.toString());
|
|
1645
|
+
expect(payload.data.user).toBeUndefined();
|
|
1646
|
+
// ...and still without paying for the guards root is exempt from.
|
|
1647
|
+
expect(stubs.teamMemberFindOneBy).not.toHaveBeenCalled();
|
|
1648
|
+
expect(stubs.methodFindOneById.get("userEmailId")).not.toHaveBeenCalled();
|
|
1649
|
+
});
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
/*
|
|
1653
|
+
* ------------------------------------------------------------------
|
|
1654
|
+
* One column, one spelling - the ambiguity that made R3 bypassable is
|
|
1655
|
+
* refused on the way in and folded away where it safely can be.
|
|
1656
|
+
* ------------------------------------------------------------------
|
|
1657
|
+
*/
|
|
1658
|
+
describe("a method column may be spelled once, not twice", () => {
|
|
1659
|
+
test("two spellings naming two different methods are refused even when BOTH belong to the rule's owner", async () => {
|
|
1660
|
+
/*
|
|
1661
|
+
* The ownership check cannot catch this one - both ids pass it - and that
|
|
1662
|
+
* is exactly why the ambiguity has to be refused in its own right. Which
|
|
1663
|
+
* address the rule ends up delivering to would otherwise be decided by a
|
|
1664
|
+
* TypeORM precedence rule (relation slot first, scalar as fallback) that
|
|
1665
|
+
* no reader of this codebase should have to know and that a TypeORM
|
|
1666
|
+
* upgrade is free to change.
|
|
1667
|
+
*/
|
|
1668
|
+
stubs.methodFindOneById
|
|
1669
|
+
.get("userEmailId")!
|
|
1670
|
+
.mockImplementation(
|
|
1671
|
+
(findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
|
|
1672
|
+
return Promise.resolve(
|
|
1673
|
+
methodRow({
|
|
1674
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1675
|
+
methodId: findBy.id,
|
|
1676
|
+
}),
|
|
1677
|
+
);
|
|
1678
|
+
},
|
|
1679
|
+
);
|
|
1680
|
+
|
|
1681
|
+
await expect(
|
|
1682
|
+
ruleService().onBeforeCreate(
|
|
1683
|
+
createPayload({
|
|
1684
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1685
|
+
columns: {
|
|
1686
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1687
|
+
userEmail: { _id: ADMIN_METHOD_ID.toString() },
|
|
1688
|
+
},
|
|
1689
|
+
}),
|
|
1690
|
+
),
|
|
1691
|
+
).rejects.toThrow(
|
|
1692
|
+
"This notification rule names two different Email notification methods",
|
|
1693
|
+
);
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
test("the refusal names WHICH spelling carried the offending id", async () => {
|
|
1697
|
+
/*
|
|
1698
|
+
* Both spellings are one column, so "your Email method belongs to
|
|
1699
|
+
* somebody else" is unactionable to a caller looking at a payload whose
|
|
1700
|
+
* `userEmailId` is perfectly correct. Naming the slot is the difference
|
|
1701
|
+
* between a message that ends the investigation and one that starts it.
|
|
1702
|
+
*/
|
|
1703
|
+
stubs.methodFindOneById
|
|
1704
|
+
.get("userEmailId")!
|
|
1705
|
+
.mockImplementation(
|
|
1706
|
+
(findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
|
|
1707
|
+
return Promise.resolve(
|
|
1708
|
+
methodRow({
|
|
1709
|
+
ownerUserId:
|
|
1710
|
+
findBy.id.toString() === ADMIN_METHOD_ID.toString()
|
|
1711
|
+
? ADMIN_USER_ID
|
|
1712
|
+
: VICTIM_USER_ID,
|
|
1713
|
+
methodId: findBy.id,
|
|
1714
|
+
}),
|
|
1715
|
+
);
|
|
1716
|
+
},
|
|
1717
|
+
);
|
|
1718
|
+
|
|
1719
|
+
await expect(
|
|
1720
|
+
ruleService().onBeforeCreate(
|
|
1721
|
+
createPayload({
|
|
1722
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1723
|
+
columns: {
|
|
1724
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1725
|
+
userEmail: { _id: ADMIN_METHOD_ID.toString() },
|
|
1726
|
+
},
|
|
1727
|
+
}),
|
|
1728
|
+
),
|
|
1729
|
+
).rejects.toThrow("The offending value was sent in userEmail.");
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
test("the same method in both spellings is not ambiguous and is accepted", async () => {
|
|
1733
|
+
const payload: CreateBy<UserNotificationRule> = createPayload({
|
|
1734
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1735
|
+
columns: {
|
|
1736
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1737
|
+
userEmail: { _id: VICTIM_METHOD_ID.toString() },
|
|
1738
|
+
},
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
await expect(
|
|
1742
|
+
ruleService().onBeforeCreate(payload),
|
|
1743
|
+
).resolves.toBeDefined();
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
test("on create the relation slot is folded into the FK column, so the ORM is left with nothing to choose between", async () => {
|
|
1747
|
+
/*
|
|
1748
|
+
* The same discipline as CreatePermission.checkCreateOwnership, which
|
|
1749
|
+
* validates the `user` relation identically to the `userId` scalar and
|
|
1750
|
+
* then clears it. Checking both spellings closes the hijack; clearing one
|
|
1751
|
+
* of them is what stops a future reader having to re-derive which of two
|
|
1752
|
+
* agreeing sources of truth actually reaches the database.
|
|
1753
|
+
*/
|
|
1754
|
+
const payload: CreateBy<UserNotificationRule> = createPayload({
|
|
1755
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1756
|
+
columns: { userEmail: { _id: VICTIM_METHOD_ID.toString() } },
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
await ruleService().onBeforeCreate(payload);
|
|
1760
|
+
|
|
1761
|
+
expect(payload.data.userEmailId?.toString()).toBe(
|
|
1762
|
+
VICTIM_METHOD_ID.toString(),
|
|
1763
|
+
);
|
|
1764
|
+
expect(payload.data.userEmail).toBeUndefined();
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
test("a relation slot carrying no id is the NULL the ORM would write, and a rule left with no method at all is refused", async () => {
|
|
1768
|
+
/*
|
|
1769
|
+
* `userEmail: {}` is an object, so TypeORM prefers it over the scalar and
|
|
1770
|
+
* writes NULL into the join column. Reading only the scalar would report
|
|
1771
|
+
* a rule that names an email; the row that lands names nothing and pages
|
|
1772
|
+
* nobody. Folding reproduces the ORM's answer, and the create invariant
|
|
1773
|
+
* then sees the rule for what it is.
|
|
1774
|
+
*/
|
|
1775
|
+
await expect(
|
|
1776
|
+
ruleService().onBeforeCreate(
|
|
1777
|
+
createPayload({
|
|
1778
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1779
|
+
columns: {
|
|
1780
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1781
|
+
userEmail: {},
|
|
1782
|
+
},
|
|
1783
|
+
}),
|
|
1784
|
+
),
|
|
1785
|
+
).rejects.toThrow(
|
|
1786
|
+
"Call, SMS, WhatsApp, Telegram, Webhook, Email, or Push notification is required",
|
|
1787
|
+
);
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
test("on update the relation slot is NOT folded into the FK column - that would launder a denied column write", async () => {
|
|
1791
|
+
/*
|
|
1792
|
+
* The two spellings do not have the same access control on update: the
|
|
1793
|
+
* relation members are `update: []` on UserNotificationRule while the
|
|
1794
|
+
* `*Id` members are open to an administrator. Moving a value from the
|
|
1795
|
+
* former into the latter would carry it across a permission boundary,
|
|
1796
|
+
* past the ColumnPermission check that runs immediately after this hook.
|
|
1797
|
+
* On this path ambiguity is answered by refusal instead.
|
|
1798
|
+
*/
|
|
1799
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1800
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
1801
|
+
] as never);
|
|
1802
|
+
|
|
1803
|
+
const payload: UpdateBy<UserNotificationRule> = updatePayload({
|
|
1804
|
+
patch: { userTelegram: { _id: VICTIM_METHOD_ID.toString() } },
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
await ruleService().onBeforeUpdate(payload);
|
|
1808
|
+
|
|
1809
|
+
const patch: Record<string, unknown> = payload.data as unknown as Record<
|
|
1810
|
+
string,
|
|
1811
|
+
unknown
|
|
1812
|
+
>;
|
|
1813
|
+
|
|
1814
|
+
expect(patch["userTelegram"]).toEqual({
|
|
1815
|
+
_id: VICTIM_METHOD_ID.toString(),
|
|
1816
|
+
});
|
|
1817
|
+
expect(patch["userTelegramId"]).toBeUndefined();
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
test("an ambiguous patch is refused on update as well", async () => {
|
|
1821
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1822
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
1823
|
+
] as never);
|
|
1824
|
+
stubs.methodFindOneById
|
|
1825
|
+
.get("userWebhookId")!
|
|
1826
|
+
.mockImplementation(
|
|
1827
|
+
(findBy: { id: ObjectID }): Promise<Record<string, unknown>> => {
|
|
1828
|
+
return Promise.resolve(
|
|
1829
|
+
methodRow({
|
|
1830
|
+
ownerUserId: VICTIM_USER_ID,
|
|
1831
|
+
methodId: findBy.id,
|
|
1832
|
+
}),
|
|
1833
|
+
);
|
|
1834
|
+
},
|
|
1835
|
+
);
|
|
1836
|
+
|
|
1837
|
+
await expect(
|
|
1838
|
+
ruleService().onBeforeUpdate(
|
|
1839
|
+
updatePayload({
|
|
1840
|
+
patch: {
|
|
1841
|
+
userWebhookId: VICTIM_METHOD_ID,
|
|
1842
|
+
userWebhook: { _id: ADMIN_METHOD_ID.toString() },
|
|
1843
|
+
},
|
|
1844
|
+
}),
|
|
1845
|
+
),
|
|
1846
|
+
).rejects.toThrow(
|
|
1847
|
+
"This notification rule names two different Webhook notification methods",
|
|
1848
|
+
);
|
|
1849
|
+
});
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
/*
|
|
1853
|
+
* ------------------------------------------------------------------
|
|
1854
|
+
* The guard's row set. DatabaseService runs these hooks BEFORE
|
|
1855
|
+
* ModelPermission narrows the query, so the narrowing has to be reproduced
|
|
1856
|
+
* here or the guard reasons about rows the caller cannot reach.
|
|
1857
|
+
* ------------------------------------------------------------------
|
|
1858
|
+
*/
|
|
1859
|
+
describe("the guard reads only rows the caller is entitled to write", () => {
|
|
1860
|
+
test("an ordinary member's guard read is confined to their own rows", async () => {
|
|
1861
|
+
/*
|
|
1862
|
+
* Permission.CurrentUser is auto-granted to every authenticated caller,
|
|
1863
|
+
* so this session holds nothing else that appears in the model's update
|
|
1864
|
+
* list. TenantPermission would rewrite their query to `userId = me`; if
|
|
1865
|
+
* the hook read the raw query with root props instead, a member could
|
|
1866
|
+
* hand it a colleague's rule id and have the guard - and the audit trail
|
|
1867
|
+
* behind it - answer about somebody else's row entirely.
|
|
1868
|
+
*/
|
|
1869
|
+
await ruleService().onBeforeUpdate(
|
|
1870
|
+
updatePayload({
|
|
1871
|
+
patch: { notifyAfterMinutes: 15 },
|
|
1872
|
+
query: { _id: RULE_ID.toString() },
|
|
1873
|
+
}),
|
|
1874
|
+
);
|
|
1875
|
+
|
|
1876
|
+
expect(guardReadQuery(stubs.ruleFindBy)["userId"]).toBe(ADMIN_USER_ID);
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
test("a query naming another project is pulled back to the session's own project", async () => {
|
|
1880
|
+
await ruleService().onBeforeUpdate(
|
|
1881
|
+
updatePayload({
|
|
1882
|
+
patch: { notifyAfterMinutes: 15 },
|
|
1883
|
+
query: {
|
|
1884
|
+
_id: RULE_ID.toString(),
|
|
1885
|
+
projectId: OTHER_PROJECT_ID.toString(),
|
|
1886
|
+
},
|
|
1887
|
+
}),
|
|
1888
|
+
);
|
|
1889
|
+
|
|
1890
|
+
expect(guardReadQuery(stubs.ruleFindBy)["projectId"]).toBe(PROJECT_ID);
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
test("an administrator is scoped to their project but NOT to their own rows - that is the repair capability", async () => {
|
|
1894
|
+
/*
|
|
1895
|
+
* The converse test, and the one that proves the narrowing is the
|
|
1896
|
+
* permission layer's rather than a blanket lock. A session holding a real
|
|
1897
|
+
* ProjectAdmin row is no longer "here purely as some logged-in user", the
|
|
1898
|
+
* ownership predicate does not apply to it, and reading a colleague's
|
|
1899
|
+
* rules is precisely what this phase exists to allow.
|
|
1900
|
+
*/
|
|
1901
|
+
await ruleService().onBeforeUpdate(
|
|
1902
|
+
updatePayload({
|
|
1903
|
+
patch: { notifyAfterMinutes: 15 },
|
|
1904
|
+
props: projectAdminProps(),
|
|
1905
|
+
}),
|
|
1906
|
+
);
|
|
1907
|
+
|
|
1908
|
+
const query: Record<string, unknown> = guardReadQuery(stubs.ruleFindBy);
|
|
1909
|
+
|
|
1910
|
+
expect(query["projectId"]).toBe(PROJECT_ID);
|
|
1911
|
+
expect(query["userId"]).toBeUndefined();
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
test("a root write is not narrowed - it is entitled to every row, and reading fewer than the write touches is the one direction this must never be wrong in", async () => {
|
|
1915
|
+
await ruleService().onBeforeUpdate(
|
|
1916
|
+
updatePayload({
|
|
1917
|
+
patch: { notifyAfterMinutes: 15 },
|
|
1918
|
+
props: { isRoot: true, userId: ADMIN_USER_ID },
|
|
1919
|
+
}),
|
|
1920
|
+
);
|
|
1921
|
+
|
|
1922
|
+
expect(guardReadQuery(stubs.ruleFindBy)).toEqual({
|
|
1923
|
+
_id: RULE_ID.toString(),
|
|
1924
|
+
});
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
test("the same narrowing is applied on the delete path", async () => {
|
|
1928
|
+
await ruleService().onBeforeDelete(
|
|
1929
|
+
deletePayload({
|
|
1930
|
+
query: {
|
|
1931
|
+
_id: RULE_ID.toString(),
|
|
1932
|
+
projectId: OTHER_PROJECT_ID.toString(),
|
|
1933
|
+
},
|
|
1934
|
+
}),
|
|
1935
|
+
);
|
|
1936
|
+
|
|
1937
|
+
const query: Record<string, unknown> = guardReadQuery(stubs.ruleFindBy);
|
|
1938
|
+
|
|
1939
|
+
expect(query["projectId"]).toBe(PROJECT_ID);
|
|
1940
|
+
expect(query["userId"]).toBe(ADMIN_USER_ID);
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
test("narrowing does not mutate the caller's query - the permission layer must still see what was actually asked for", async () => {
|
|
1944
|
+
const payload: UpdateBy<UserNotificationRule> = updatePayload({
|
|
1945
|
+
patch: { notifyAfterMinutes: 15 },
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
await ruleService().onBeforeUpdate(payload);
|
|
1949
|
+
|
|
1950
|
+
expect(payload.query).toEqual({ _id: RULE_ID.toString() });
|
|
1951
|
+
});
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
/*
|
|
1955
|
+
* ------------------------------------------------------------------
|
|
1956
|
+
* The row-level invariants, which update could break until now.
|
|
1957
|
+
* ------------------------------------------------------------------
|
|
1958
|
+
*/
|
|
1959
|
+
describe("update cannot leave a rule in a state create would have refused", () => {
|
|
1960
|
+
test("opting a rule out while it still carries a method is refused", async () => {
|
|
1961
|
+
/*
|
|
1962
|
+
* "Reach me here; also never reach me." Create has always refused it;
|
|
1963
|
+
* update reached it in one field, and the resulting row is not merely
|
|
1964
|
+
* untidy - the fallback treats an opt-out row as a deliberate choice to
|
|
1965
|
+
* stay silent, so this is a rule that suppresses paging while looking
|
|
1966
|
+
* like one that delivers it.
|
|
1967
|
+
*/
|
|
1968
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1969
|
+
persistedRule({
|
|
1970
|
+
userId: VICTIM_USER_ID,
|
|
1971
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1972
|
+
}),
|
|
1973
|
+
] as never);
|
|
1974
|
+
|
|
1975
|
+
await expect(
|
|
1976
|
+
ruleService().onBeforeUpdate(
|
|
1977
|
+
updatePayload({ patch: { isOptOut: true } }),
|
|
1978
|
+
),
|
|
1979
|
+
).rejects.toThrow(
|
|
1980
|
+
"An opt-out notification rule cannot have a notification method.",
|
|
1981
|
+
);
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
test("clearing the last method on a rule that still pages is refused", async () => {
|
|
1985
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
1986
|
+
persistedRule({
|
|
1987
|
+
userId: VICTIM_USER_ID,
|
|
1988
|
+
isOptOut: false,
|
|
1989
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
1990
|
+
}),
|
|
1991
|
+
] as never);
|
|
1992
|
+
|
|
1993
|
+
await expect(
|
|
1994
|
+
ruleService().onBeforeUpdate(
|
|
1995
|
+
updatePayload({ patch: { userEmailId: null } }),
|
|
1996
|
+
),
|
|
1997
|
+
).rejects.toThrow(
|
|
1998
|
+
"Call, SMS, WhatsApp, Telegram, Webhook, Email, or Push notification is required",
|
|
1999
|
+
);
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
test("clearing the method and opting out in the same patch is coherent and allowed", async () => {
|
|
2003
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
2004
|
+
persistedRule({
|
|
2005
|
+
userId: VICTIM_USER_ID,
|
|
2006
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
2007
|
+
}),
|
|
2008
|
+
] as never);
|
|
2009
|
+
|
|
2010
|
+
await expect(
|
|
2011
|
+
ruleService().onBeforeUpdate(
|
|
2012
|
+
updatePayload({ patch: { isOptOut: true, userEmailId: null } }),
|
|
2013
|
+
),
|
|
2014
|
+
).resolves.toBeDefined();
|
|
2015
|
+
});
|
|
2016
|
+
|
|
2017
|
+
test("turning opt-out off while naming a method in the same patch is allowed", async () => {
|
|
2018
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
2019
|
+
persistedRule({ userId: VICTIM_USER_ID, isOptOut: true }),
|
|
2020
|
+
] as never);
|
|
2021
|
+
|
|
2022
|
+
await expect(
|
|
2023
|
+
ruleService().onBeforeUpdate(
|
|
2024
|
+
updatePayload({
|
|
2025
|
+
patch: { isOptOut: false, userSmsId: VICTIM_METHOD_ID },
|
|
2026
|
+
}),
|
|
2027
|
+
),
|
|
2028
|
+
).resolves.toBeDefined();
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
test("swapping one method for another leaves the rule coherent", async () => {
|
|
2032
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
2033
|
+
persistedRule({
|
|
2034
|
+
userId: VICTIM_USER_ID,
|
|
2035
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
2036
|
+
}),
|
|
2037
|
+
] as never);
|
|
2038
|
+
|
|
2039
|
+
await expect(
|
|
2040
|
+
ruleService().onBeforeUpdate(
|
|
2041
|
+
updatePayload({
|
|
2042
|
+
patch: { userEmailId: null, userSmsId: VICTIM_METHOD_ID },
|
|
2043
|
+
}),
|
|
2044
|
+
),
|
|
2045
|
+
).resolves.toBeDefined();
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
test("a patch that touches neither opt-out nor any method leaves an already-broken legacy row alone", async () => {
|
|
2049
|
+
/*
|
|
2050
|
+
* Rows written before these invariants existed are out there. Refusing to
|
|
2051
|
+
* let anyone edit the notify delay on one would make it unrepairable
|
|
2052
|
+
* through the API that has to repair it, so the check answers for the
|
|
2053
|
+
* state the PATCH produces rather than policing the row it lands on.
|
|
2054
|
+
*/
|
|
2055
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
2056
|
+
persistedRule({ userId: VICTIM_USER_ID, isOptOut: false }),
|
|
2057
|
+
] as never);
|
|
2058
|
+
|
|
2059
|
+
await expect(
|
|
2060
|
+
ruleService().onBeforeUpdate(
|
|
2061
|
+
updatePayload({ patch: { notifyAfterMinutes: 20 } }),
|
|
2062
|
+
),
|
|
2063
|
+
).resolves.toBeDefined();
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
test("the invariant is checked against EVERY affected row, not just the first", async () => {
|
|
2067
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
2068
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
2069
|
+
persistedRule({
|
|
2070
|
+
id: SECOND_RULE_ID,
|
|
2071
|
+
_id: SECOND_RULE_ID.toString(),
|
|
2072
|
+
userId: VICTIM_USER_ID,
|
|
2073
|
+
userEmailId: VICTIM_METHOD_ID,
|
|
2074
|
+
}),
|
|
2075
|
+
] as never);
|
|
2076
|
+
|
|
2077
|
+
await expect(
|
|
2078
|
+
ruleService().onBeforeUpdate(
|
|
2079
|
+
updatePayload({ patch: { isOptOut: true } }),
|
|
2080
|
+
),
|
|
2081
|
+
).rejects.toThrow(
|
|
2082
|
+
"An opt-out notification rule cannot have a notification method.",
|
|
2083
|
+
);
|
|
2084
|
+
});
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
/*
|
|
2088
|
+
* ------------------------------------------------------------------
|
|
2089
|
+
* Delete - the most destructive of the three verbs, and the last one to
|
|
2090
|
+
* get a guard.
|
|
2091
|
+
* ------------------------------------------------------------------
|
|
2092
|
+
*/
|
|
2093
|
+
describe("deleting somebody else's notification rules leaves a record and a warning", () => {
|
|
2094
|
+
test("the rows are read BEFORE the delete, with root props, because afterwards there is nothing to read", async () => {
|
|
2095
|
+
await ruleService().onBeforeDelete(deletePayload());
|
|
2096
|
+
|
|
2097
|
+
const findByArgs: {
|
|
2098
|
+
query: Record<string, unknown>;
|
|
2099
|
+
props: { isRoot: boolean; ignoreHooks: boolean };
|
|
2100
|
+
} = stubs.ruleFindBy.mock.calls[0]![0] as {
|
|
2101
|
+
query: Record<string, unknown>;
|
|
2102
|
+
props: { isRoot: boolean; ignoreHooks: boolean };
|
|
2103
|
+
};
|
|
2104
|
+
|
|
2105
|
+
expect(findByArgs.props.isRoot).toBe(true);
|
|
2106
|
+
expect(findByArgs.query["_id"]).toBe(RULE_ID.toString());
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
test("the snapshot is carried forward, because the audit entry cannot be rebuilt from a row that no longer exists", async () => {
|
|
2110
|
+
stubs.ruleFindBy.mockResolvedValue([
|
|
2111
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
2112
|
+
] as never);
|
|
2113
|
+
|
|
2114
|
+
const onDelete: OnDelete<UserNotificationRule> =
|
|
2115
|
+
await ruleService().onBeforeDelete(deletePayload());
|
|
2116
|
+
|
|
2117
|
+
expect(onDelete.carryForward.deletedRules).toHaveLength(1);
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
test("an administrative delete is logged against the actor and the row's PERSISTED owner", async () => {
|
|
2121
|
+
await ruleService().onDeleteSuccess(
|
|
2122
|
+
{
|
|
2123
|
+
deleteBy: deletePayload(),
|
|
2124
|
+
carryForward: {
|
|
2125
|
+
deletedRules: [persistedRule({ userId: VICTIM_USER_ID })],
|
|
2126
|
+
},
|
|
2127
|
+
},
|
|
2128
|
+
[RULE_ID],
|
|
2129
|
+
);
|
|
2130
|
+
|
|
2131
|
+
await flushAsync();
|
|
2132
|
+
|
|
2133
|
+
const trail: Array<AdminChangeLogAttributes> = adminChangeLogAttributes(
|
|
2134
|
+
stubs.loggerInfo,
|
|
2135
|
+
);
|
|
2136
|
+
|
|
2137
|
+
expect(trail).toHaveLength(1);
|
|
2138
|
+
expect(trail[0]!.action).toBe("Delete");
|
|
2139
|
+
expect(trail[0]!.actorUserId).toBe(ADMIN_USER_ID.toString());
|
|
2140
|
+
expect(trail[0]!.userId).toBe(VICTIM_USER_ID.toString());
|
|
2141
|
+
expect(trail[0]!.userNotificationRuleId).toBe(RULE_ID.toString());
|
|
2142
|
+
|
|
2143
|
+
expect(stubs.auditRecordDelete).toHaveBeenCalledTimes(1);
|
|
2144
|
+
|
|
2145
|
+
const auditArgs: { deletedItem: UserNotificationRule; itemId: ObjectID } =
|
|
2146
|
+
stubs.auditRecordDelete.mock.calls[0]![0] as {
|
|
2147
|
+
deletedItem: UserNotificationRule;
|
|
2148
|
+
itemId: ObjectID;
|
|
2149
|
+
};
|
|
2150
|
+
|
|
2151
|
+
expect(auditArgs.itemId.toString()).toBe(RULE_ID.toString());
|
|
2152
|
+
expect(auditArgs.deletedItem.userId?.toString()).toBe(
|
|
2153
|
+
VICTIM_USER_ID.toString(),
|
|
2154
|
+
);
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
test("the owner is told, and told what it means rather than merely that it happened", async () => {
|
|
2158
|
+
/*
|
|
2159
|
+
* Deletion is the one action that can leave a responder silently
|
|
2160
|
+
* unreachable, so the mail says so. "An administrator changed your rules"
|
|
2161
|
+
* is a prompt to go and look; "you may no longer be notified when you are
|
|
2162
|
+
* on call" is the consequence, and it is the reason this epic exists.
|
|
2163
|
+
*/
|
|
2164
|
+
await ruleService().onDeleteSuccess(
|
|
2165
|
+
{
|
|
2166
|
+
deleteBy: deletePayload(),
|
|
2167
|
+
carryForward: {
|
|
2168
|
+
deletedRules: [persistedRule({ userId: VICTIM_USER_ID })],
|
|
2169
|
+
},
|
|
2170
|
+
},
|
|
2171
|
+
[RULE_ID],
|
|
2172
|
+
);
|
|
2173
|
+
|
|
2174
|
+
await flushAsync();
|
|
2175
|
+
|
|
2176
|
+
expect(stubs.sendMail).toHaveBeenCalledTimes(1);
|
|
2177
|
+
|
|
2178
|
+
const mail: {
|
|
2179
|
+
toEmail: Email;
|
|
2180
|
+
subject: string;
|
|
2181
|
+
vars: { message: string };
|
|
2182
|
+
} = stubs.sendMail.mock.calls[0]![0] as {
|
|
2183
|
+
toEmail: Email;
|
|
2184
|
+
subject: string;
|
|
2185
|
+
vars: { message: string };
|
|
2186
|
+
};
|
|
2187
|
+
|
|
2188
|
+
expect(mail.toEmail.toString()).toBe(VICTIM_EMAIL);
|
|
2189
|
+
expect(mail.subject).toContain("deleted");
|
|
2190
|
+
expect(mail.vars.message).toContain("no longer be notified");
|
|
2191
|
+
expect(mail.vars.message).toContain(ADMIN_EMAIL);
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
test("deleting your own rules is neither audited as administrative nor announced", async () => {
|
|
2195
|
+
await ruleService().onDeleteSuccess(
|
|
2196
|
+
{
|
|
2197
|
+
deleteBy: deletePayload(),
|
|
2198
|
+
carryForward: {
|
|
2199
|
+
deletedRules: [persistedRule({ userId: ADMIN_USER_ID })],
|
|
2200
|
+
},
|
|
2201
|
+
},
|
|
2202
|
+
[RULE_ID],
|
|
2203
|
+
);
|
|
2204
|
+
|
|
2205
|
+
await flushAsync();
|
|
2206
|
+
|
|
2207
|
+
expect(stubs.sendMail).not.toHaveBeenCalled();
|
|
2208
|
+
expect(stubs.auditRecordDelete).not.toHaveBeenCalled();
|
|
2209
|
+
expect(adminChangeLogAttributes(stubs.loggerInfo)).toHaveLength(0);
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2212
|
+
test("only rows the delete actually removed are reported", async () => {
|
|
2213
|
+
/*
|
|
2214
|
+
* The hook reads every row the narrowed query matches; _deleteBy then
|
|
2215
|
+
* applies the caller's own skip/limit on top. A warning about a rule that
|
|
2216
|
+
* still exists is a false alarm, and false alarms are how the true one
|
|
2217
|
+
* gets ignored.
|
|
2218
|
+
*/
|
|
2219
|
+
await ruleService().onDeleteSuccess(
|
|
2220
|
+
{
|
|
2221
|
+
deleteBy: deletePayload(),
|
|
2222
|
+
carryForward: {
|
|
2223
|
+
deletedRules: [
|
|
2224
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
2225
|
+
persistedRule({
|
|
2226
|
+
id: SECOND_RULE_ID,
|
|
2227
|
+
_id: SECOND_RULE_ID.toString(),
|
|
2228
|
+
userId: STRANGER_USER_ID,
|
|
2229
|
+
}),
|
|
2230
|
+
],
|
|
2231
|
+
},
|
|
2232
|
+
},
|
|
2233
|
+
[RULE_ID],
|
|
2234
|
+
);
|
|
2235
|
+
|
|
2236
|
+
await flushAsync();
|
|
2237
|
+
|
|
2238
|
+
expect(stubs.sendMail).toHaveBeenCalledTimes(1);
|
|
2239
|
+
expect(
|
|
2240
|
+
(
|
|
2241
|
+
stubs.sendMail.mock.calls[0]![0] as { toEmail: Email }
|
|
2242
|
+
).toEmail.toString(),
|
|
2243
|
+
).toBe(VICTIM_EMAIL);
|
|
2244
|
+
});
|
|
2245
|
+
|
|
2246
|
+
test("a sweep across several of one person's rules audits each row but mails them once", async () => {
|
|
2247
|
+
await ruleService().onDeleteSuccess(
|
|
2248
|
+
{
|
|
2249
|
+
deleteBy: deletePayload(),
|
|
2250
|
+
carryForward: {
|
|
2251
|
+
deletedRules: [
|
|
2252
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
2253
|
+
persistedRule({
|
|
2254
|
+
id: SECOND_RULE_ID,
|
|
2255
|
+
_id: SECOND_RULE_ID.toString(),
|
|
2256
|
+
userId: VICTIM_USER_ID,
|
|
2257
|
+
}),
|
|
2258
|
+
],
|
|
2259
|
+
},
|
|
2260
|
+
},
|
|
2261
|
+
[RULE_ID, SECOND_RULE_ID],
|
|
2262
|
+
);
|
|
2263
|
+
|
|
2264
|
+
await flushAsync();
|
|
2265
|
+
|
|
2266
|
+
expect(stubs.auditRecordDelete).toHaveBeenCalledTimes(2);
|
|
2267
|
+
expect(stubs.sendMail).toHaveBeenCalledTimes(1);
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
test("a delete with no actor - a worker, a cascade, a migration - reads nothing and reports nothing", async () => {
|
|
2271
|
+
const onDelete: OnDelete<UserNotificationRule> =
|
|
2272
|
+
await ruleService().onBeforeDelete(
|
|
2273
|
+
deletePayload({ props: { isRoot: true } }),
|
|
2274
|
+
);
|
|
2275
|
+
|
|
2276
|
+
await ruleService().onDeleteSuccess(onDelete, [RULE_ID]);
|
|
2277
|
+
|
|
2278
|
+
await flushAsync();
|
|
2279
|
+
|
|
2280
|
+
expect(stubs.ruleFindBy).not.toHaveBeenCalled();
|
|
2281
|
+
expect(stubs.sendMail).not.toHaveBeenCalled();
|
|
2282
|
+
expect(stubs.auditRecordDelete).not.toHaveBeenCalled();
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
test("a mail server that is down cannot fail a delete that already happened", async () => {
|
|
2286
|
+
stubs.sendMail.mockRejectedValue(new Error("smtp is down") as never);
|
|
2287
|
+
|
|
2288
|
+
await expect(
|
|
2289
|
+
ruleService().onDeleteSuccess(
|
|
2290
|
+
{
|
|
2291
|
+
deleteBy: deletePayload(),
|
|
2292
|
+
carryForward: {
|
|
2293
|
+
deletedRules: [persistedRule({ userId: VICTIM_USER_ID })],
|
|
2294
|
+
},
|
|
2295
|
+
},
|
|
2296
|
+
[RULE_ID],
|
|
2297
|
+
),
|
|
2298
|
+
).resolves.toBeDefined();
|
|
2299
|
+
|
|
2300
|
+
await flushAsync();
|
|
2301
|
+
|
|
2302
|
+
expect(stubs.loggerError).toHaveBeenCalled();
|
|
2303
|
+
});
|
|
2304
|
+
});
|
|
2305
|
+
|
|
2306
|
+
/*
|
|
2307
|
+
* ------------------------------------------------------------------
|
|
2308
|
+
* R6 - the trail is keyed on the server's actor and the persisted owner.
|
|
2309
|
+
* ------------------------------------------------------------------
|
|
2310
|
+
*/
|
|
2311
|
+
describe("R6: audit and notification are keyed on props.userId versus the persisted owner", () => {
|
|
2312
|
+
test("creating somebody else's rule is logged against the actor and the row's PERSISTED owner", async () => {
|
|
2313
|
+
/*
|
|
2314
|
+
* The body lies about ownership in the direction that would flatter the
|
|
2315
|
+
* caller - it claims the row is the admin's own, which would make the
|
|
2316
|
+
* change unremarkable and unreported. The persisted row says otherwise,
|
|
2317
|
+
* and only the persisted row is consulted.
|
|
2318
|
+
*/
|
|
2319
|
+
const payload: CreateBy<UserNotificationRule> = createPayload({
|
|
2320
|
+
ownerUserId: ADMIN_USER_ID,
|
|
2321
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
const createdItem: UserNotificationRule = persistedRule({
|
|
2325
|
+
userId: VICTIM_USER_ID,
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
const returned: UserNotificationRule =
|
|
2329
|
+
await ruleService().onCreateSuccess(
|
|
2330
|
+
{ createBy: payload, carryForward: null },
|
|
2331
|
+
createdItem,
|
|
2332
|
+
);
|
|
2333
|
+
|
|
2334
|
+
await flushAsync();
|
|
2335
|
+
|
|
2336
|
+
expect(returned).toBe(createdItem);
|
|
2337
|
+
|
|
2338
|
+
const trail: Array<AdminChangeLogAttributes> = adminChangeLogAttributes(
|
|
2339
|
+
stubs.loggerInfo,
|
|
2340
|
+
);
|
|
2341
|
+
|
|
2342
|
+
expect(trail).toHaveLength(1);
|
|
2343
|
+
expect(trail[0]!.actorUserId).toBe(ADMIN_USER_ID.toString());
|
|
2344
|
+
expect(trail[0]!.userId).toBe(VICTIM_USER_ID.toString());
|
|
2345
|
+
expect(trail[0]!.userNotificationRuleId).toBe(RULE_ID.toString());
|
|
2346
|
+
|
|
2347
|
+
expect(stubs.auditRecordCreate).toHaveBeenCalledTimes(1);
|
|
2348
|
+
|
|
2349
|
+
const auditArgs: {
|
|
2350
|
+
createdItem: UserNotificationRule;
|
|
2351
|
+
props: DatabaseCommonInteractionProps;
|
|
2352
|
+
} = stubs.auditRecordCreate.mock.calls[0]![0] as {
|
|
2353
|
+
createdItem: UserNotificationRule;
|
|
2354
|
+
props: DatabaseCommonInteractionProps;
|
|
2355
|
+
};
|
|
2356
|
+
|
|
2357
|
+
expect(auditArgs.createdItem).toBe(createdItem);
|
|
2358
|
+
expect(auditArgs.props.userId?.toString()).toBe(ADMIN_USER_ID.toString());
|
|
2359
|
+
});
|
|
2360
|
+
|
|
2361
|
+
test("the owner is emailed, and the mail names the actor by address rather than by display name alone", async () => {
|
|
2362
|
+
await ruleService().onCreateSuccess(
|
|
2363
|
+
{
|
|
2364
|
+
createBy: createPayload({
|
|
2365
|
+
ownerUserId: VICTIM_USER_ID,
|
|
2366
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
2367
|
+
}),
|
|
2368
|
+
carryForward: null,
|
|
2369
|
+
},
|
|
2370
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
2371
|
+
);
|
|
2372
|
+
|
|
2373
|
+
await flushAsync();
|
|
2374
|
+
|
|
2375
|
+
expect(stubs.sendMail).toHaveBeenCalledTimes(1);
|
|
2376
|
+
|
|
2377
|
+
const mail: { toEmail: Email; vars: { message: string } } = stubs.sendMail
|
|
2378
|
+
.mock.calls[0]![0] as {
|
|
2379
|
+
toEmail: Email;
|
|
2380
|
+
vars: { message: string };
|
|
2381
|
+
};
|
|
2382
|
+
const mailOptions: { userId: ObjectID } = stubs.sendMail.mock
|
|
2383
|
+
.calls[0]![1] as { userId: ObjectID };
|
|
2384
|
+
|
|
2385
|
+
// The person whose pages changed is the one told about it.
|
|
2386
|
+
expect(mail.toEmail.toString()).toBe(VICTIM_EMAIL);
|
|
2387
|
+
expect(mailOptions.userId.toString()).toBe(VICTIM_USER_ID.toString());
|
|
2388
|
+
/*
|
|
2389
|
+
* Display names are neither unique nor trustworthy - a user can set their
|
|
2390
|
+
* own - so "Ada Admin changed your rules" is not something the reader can
|
|
2391
|
+
* act on. An address is.
|
|
2392
|
+
*/
|
|
2393
|
+
expect(mail.vars.message).toContain(ADMIN_EMAIL);
|
|
2394
|
+
});
|
|
2395
|
+
|
|
2396
|
+
test("a body naming a victim cannot fabricate a report about a row the caller actually owns", async () => {
|
|
2397
|
+
/*
|
|
2398
|
+
* The inverse forgery: the body claims the row belongs to the victim so
|
|
2399
|
+
* that the trail records an administrative change that never happened.
|
|
2400
|
+
* An audit line an attacker can author is worse than none, because it
|
|
2401
|
+
* reads as evidence.
|
|
2402
|
+
*/
|
|
2403
|
+
await ruleService().onCreateSuccess(
|
|
2404
|
+
{
|
|
2405
|
+
createBy: createPayload({
|
|
2406
|
+
ownerUserId: VICTIM_USER_ID,
|
|
2407
|
+
columns: { userEmailId: ADMIN_METHOD_ID },
|
|
2408
|
+
}),
|
|
2409
|
+
carryForward: null,
|
|
2410
|
+
},
|
|
2411
|
+
persistedRule({ userId: ADMIN_USER_ID }),
|
|
2412
|
+
);
|
|
2413
|
+
|
|
2414
|
+
await flushAsync();
|
|
2415
|
+
|
|
2416
|
+
expect(adminChangeLogAttributes(stubs.loggerInfo)).toHaveLength(0);
|
|
2417
|
+
expect(stubs.auditRecordCreate).not.toHaveBeenCalled();
|
|
2418
|
+
expect(stubs.sendMail).not.toHaveBeenCalled();
|
|
2419
|
+
});
|
|
2420
|
+
|
|
2421
|
+
test("a self-edit is neither audited as administrative nor announced", async () => {
|
|
2422
|
+
await ruleService().onUpdateSuccess(
|
|
2423
|
+
{
|
|
2424
|
+
updateBy: updatePayload({ patch: { notifyAfterMinutes: 5 } }),
|
|
2425
|
+
carryForward: {
|
|
2426
|
+
affectedRules: [persistedRule({ userId: ADMIN_USER_ID })],
|
|
2427
|
+
},
|
|
2428
|
+
},
|
|
2429
|
+
[RULE_ID],
|
|
2430
|
+
);
|
|
2431
|
+
|
|
2432
|
+
await flushAsync();
|
|
2433
|
+
|
|
2434
|
+
expect(stubs.sendMail).not.toHaveBeenCalled();
|
|
2435
|
+
expect(stubs.auditRecordUpdate).not.toHaveBeenCalled();
|
|
2436
|
+
expect(adminChangeLogAttributes(stubs.loggerInfo)).toHaveLength(0);
|
|
2437
|
+
});
|
|
2438
|
+
|
|
2439
|
+
test("a mail server that is down cannot fail a write that already happened", async () => {
|
|
2440
|
+
/*
|
|
2441
|
+
* By the time any of this runs the row is committed. Surfacing an SMTP
|
|
2442
|
+
* failure as a failed write would tell the caller their change did not
|
|
2443
|
+
* happen when it did - a worse outcome than a missing warning email.
|
|
2444
|
+
*/
|
|
2445
|
+
stubs.sendMail.mockRejectedValue(new Error("smtp is down") as never);
|
|
2446
|
+
|
|
2447
|
+
const createdItem: UserNotificationRule = persistedRule({
|
|
2448
|
+
userId: VICTIM_USER_ID,
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
await expect(
|
|
2452
|
+
ruleService().onCreateSuccess(
|
|
2453
|
+
{
|
|
2454
|
+
createBy: createPayload({
|
|
2455
|
+
ownerUserId: VICTIM_USER_ID,
|
|
2456
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
2457
|
+
}),
|
|
2458
|
+
carryForward: null,
|
|
2459
|
+
},
|
|
2460
|
+
createdItem,
|
|
2461
|
+
),
|
|
2462
|
+
).resolves.toBe(createdItem);
|
|
2463
|
+
|
|
2464
|
+
await flushAsync();
|
|
2465
|
+
|
|
2466
|
+
// ...and it is not swallowed silently either.
|
|
2467
|
+
expect(stubs.loggerError).toHaveBeenCalled();
|
|
2468
|
+
});
|
|
2469
|
+
|
|
2470
|
+
test("an audit sink that throws cannot fail the write either", async () => {
|
|
2471
|
+
stubs.auditRecordCreate.mockRejectedValue(
|
|
2472
|
+
new Error("clickhouse unreachable") as never,
|
|
2473
|
+
);
|
|
2474
|
+
|
|
2475
|
+
const createdItem: UserNotificationRule = persistedRule({
|
|
2476
|
+
userId: VICTIM_USER_ID,
|
|
2477
|
+
});
|
|
2478
|
+
|
|
2479
|
+
await expect(
|
|
2480
|
+
ruleService().onCreateSuccess(
|
|
2481
|
+
{
|
|
2482
|
+
createBy: createPayload({
|
|
2483
|
+
ownerUserId: VICTIM_USER_ID,
|
|
2484
|
+
columns: { userEmailId: VICTIM_METHOD_ID },
|
|
2485
|
+
}),
|
|
2486
|
+
carryForward: null,
|
|
2487
|
+
},
|
|
2488
|
+
createdItem,
|
|
2489
|
+
),
|
|
2490
|
+
).resolves.toBe(createdItem);
|
|
2491
|
+
|
|
2492
|
+
await flushAsync();
|
|
2493
|
+
|
|
2494
|
+
/*
|
|
2495
|
+
* The dependable half of the trail is the log line, which is written
|
|
2496
|
+
* BEFORE the sink is called precisely so that a sink outage cannot erase
|
|
2497
|
+
* the record that this happened. Audit-log rows only exist on enterprise
|
|
2498
|
+
* builds with audit logging switched on for the project; the log line
|
|
2499
|
+
* exists everywhere.
|
|
2500
|
+
*/
|
|
2501
|
+
expect(adminChangeLogAttributes(stubs.loggerInfo)).toHaveLength(1);
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
test("updating somebody else's rules audits each row against the owner the DATABASE had", async () => {
|
|
2505
|
+
await ruleService().onUpdateSuccess(
|
|
2506
|
+
{
|
|
2507
|
+
updateBy: updatePayload({ patch: { notifyAfterMinutes: 30 } }),
|
|
2508
|
+
carryForward: {
|
|
2509
|
+
affectedRules: [
|
|
2510
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
2511
|
+
persistedRule({
|
|
2512
|
+
id: SECOND_RULE_ID,
|
|
2513
|
+
_id: SECOND_RULE_ID.toString(),
|
|
2514
|
+
userId: VICTIM_USER_ID,
|
|
2515
|
+
}),
|
|
2516
|
+
],
|
|
2517
|
+
},
|
|
2518
|
+
},
|
|
2519
|
+
[RULE_ID, SECOND_RULE_ID],
|
|
2520
|
+
);
|
|
2521
|
+
|
|
2522
|
+
await flushAsync();
|
|
2523
|
+
|
|
2524
|
+
const trail: Array<AdminChangeLogAttributes> = adminChangeLogAttributes(
|
|
2525
|
+
stubs.loggerInfo,
|
|
2526
|
+
);
|
|
2527
|
+
|
|
2528
|
+
// One audit entry per ROW: that is what an investigator reconstructs from.
|
|
2529
|
+
expect(trail).toHaveLength(2);
|
|
2530
|
+
expect(stubs.auditRecordUpdate).toHaveBeenCalledTimes(2);
|
|
2531
|
+
|
|
2532
|
+
const auditArgs: { before: UserNotificationRule; itemId: ObjectID } =
|
|
2533
|
+
stubs.auditRecordUpdate.mock.calls[0]![0] as {
|
|
2534
|
+
before: UserNotificationRule;
|
|
2535
|
+
itemId: ObjectID;
|
|
2536
|
+
};
|
|
2537
|
+
|
|
2538
|
+
expect(auditArgs.itemId.toString()).toBe(RULE_ID.toString());
|
|
2539
|
+
expect(auditArgs.before.userId?.toString()).toBe(
|
|
2540
|
+
VICTIM_USER_ID.toString(),
|
|
2541
|
+
);
|
|
2542
|
+
|
|
2543
|
+
/*
|
|
2544
|
+
* ...but ONE mail per person. Twenty copies of "an admin changed your
|
|
2545
|
+
* rules" is how a warning becomes a filter rule, and then the one that
|
|
2546
|
+
* mattered is never read.
|
|
2547
|
+
*/
|
|
2548
|
+
expect(stubs.sendMail).toHaveBeenCalledTimes(1);
|
|
2549
|
+
expect(
|
|
2550
|
+
(
|
|
2551
|
+
stubs.sendMail.mock.calls[0]![0] as { toEmail: Email }
|
|
2552
|
+
).toEmail.toString(),
|
|
2553
|
+
).toBe(VICTIM_EMAIL);
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
test("in one update touching two people's rules, each person is told", async () => {
|
|
2557
|
+
stubs.userFindOneById.mockImplementation(
|
|
2558
|
+
(findBy: { id: ObjectID }): Promise<User> => {
|
|
2559
|
+
return Promise.resolve({
|
|
2560
|
+
id: findBy.id,
|
|
2561
|
+
_id: findBy.id.toString(),
|
|
2562
|
+
name: new Name("Somebody"),
|
|
2563
|
+
email: new Email(`${findBy.id.toString()}@example.test`),
|
|
2564
|
+
} as unknown as User);
|
|
2565
|
+
},
|
|
2566
|
+
);
|
|
2567
|
+
|
|
2568
|
+
await ruleService().onUpdateSuccess(
|
|
2569
|
+
{
|
|
2570
|
+
updateBy: updatePayload({ patch: { notifyAfterMinutes: 30 } }),
|
|
2571
|
+
carryForward: {
|
|
2572
|
+
affectedRules: [
|
|
2573
|
+
persistedRule({ userId: VICTIM_USER_ID }),
|
|
2574
|
+
persistedRule({
|
|
2575
|
+
id: SECOND_RULE_ID,
|
|
2576
|
+
_id: SECOND_RULE_ID.toString(),
|
|
2577
|
+
userId: STRANGER_USER_ID,
|
|
2578
|
+
}),
|
|
2579
|
+
],
|
|
2580
|
+
},
|
|
2581
|
+
},
|
|
2582
|
+
[RULE_ID, SECOND_RULE_ID],
|
|
2583
|
+
);
|
|
2584
|
+
|
|
2585
|
+
await flushAsync();
|
|
2586
|
+
|
|
2587
|
+
const recipients: Array<string> = stubs.sendMail.mock.calls.map(
|
|
2588
|
+
(call: Array<unknown>): string => {
|
|
2589
|
+
return (call[0] as { toEmail: Email }).toEmail.toString();
|
|
2590
|
+
},
|
|
2591
|
+
);
|
|
2592
|
+
|
|
2593
|
+
expect(recipients.sort()).toEqual(
|
|
2594
|
+
[
|
|
2595
|
+
`${VICTIM_USER_ID.toString()}@example.test`,
|
|
2596
|
+
`${STRANGER_USER_ID.toString()}@example.test`,
|
|
2597
|
+
].sort(),
|
|
2598
|
+
);
|
|
2599
|
+
});
|
|
2600
|
+
|
|
2601
|
+
test("a write with no actor - a worker, a migration - reports nothing", async () => {
|
|
2602
|
+
/*
|
|
2603
|
+
* There is no human to name, so there is nothing to say. Reporting
|
|
2604
|
+
* "somebody changed your rules" for every internal seeding pass would
|
|
2605
|
+
* train people to ignore the message that matters.
|
|
2606
|
+
*/
|
|
2607
|
+
await ruleService().onUpdateSuccess(
|
|
2608
|
+
{
|
|
2609
|
+
updateBy: updatePayload({
|
|
2610
|
+
patch: { notifyAfterMinutes: 5 },
|
|
2611
|
+
props: { isRoot: true },
|
|
2612
|
+
}),
|
|
2613
|
+
carryForward: {
|
|
2614
|
+
affectedRules: [persistedRule({ userId: VICTIM_USER_ID })],
|
|
2615
|
+
},
|
|
2616
|
+
},
|
|
2617
|
+
[RULE_ID],
|
|
2618
|
+
);
|
|
2619
|
+
|
|
2620
|
+
await flushAsync();
|
|
2621
|
+
|
|
2622
|
+
expect(stubs.sendMail).not.toHaveBeenCalled();
|
|
2623
|
+
expect(stubs.auditRecordUpdate).not.toHaveBeenCalled();
|
|
2624
|
+
});
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
/*
|
|
2628
|
+
* ------------------------------------------------------------------
|
|
2629
|
+
* Defence in depth - a mismatched row must be inert at delivery time.
|
|
2630
|
+
* ------------------------------------------------------------------
|
|
2631
|
+
*/
|
|
2632
|
+
describe("delivery refuses a rule whose method belongs to somebody else", () => {
|
|
2633
|
+
function loadedRule(data: {
|
|
2634
|
+
ruleOwnerUserId: ObjectID | undefined;
|
|
2635
|
+
relationColumn: string;
|
|
2636
|
+
methodOwnerUserId: ObjectID | undefined;
|
|
2637
|
+
}): UserNotificationRule {
|
|
2638
|
+
const rule: UserNotificationRule = new UserNotificationRule();
|
|
2639
|
+
rule.id = RULE_ID;
|
|
2640
|
+
rule.projectId = PROJECT_ID;
|
|
2641
|
+
|
|
2642
|
+
if (data.ruleOwnerUserId) {
|
|
2643
|
+
rule.userId = data.ruleOwnerUserId;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
(rule as unknown as Record<string, unknown>)[data.relationColumn] = {
|
|
2647
|
+
_id: VICTIM_METHOD_ID.toString(),
|
|
2648
|
+
id: VICTIM_METHOD_ID,
|
|
2649
|
+
isVerified: true,
|
|
2650
|
+
userId: data.methodOwnerUserId,
|
|
2651
|
+
};
|
|
2652
|
+
|
|
2653
|
+
return rule;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
test("a rule whose email method belongs to another user is not delivered at all", async () => {
|
|
2657
|
+
stubs.ruleFindOneById.mockResolvedValue(
|
|
2658
|
+
loadedRule({
|
|
2659
|
+
ruleOwnerUserId: VICTIM_USER_ID,
|
|
2660
|
+
relationColumn: "userEmail",
|
|
2661
|
+
methodOwnerUserId: ADMIN_USER_ID,
|
|
2662
|
+
}) as never,
|
|
2663
|
+
);
|
|
2664
|
+
|
|
2665
|
+
await UserNotificationRuleService.executeNotificationRuleItem(
|
|
2666
|
+
RULE_ID,
|
|
2667
|
+
executeOptions(),
|
|
2668
|
+
);
|
|
2669
|
+
|
|
2670
|
+
expect(stubs.deliver).not.toHaveBeenCalled();
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
test("the refusal is recorded on the on-call log, naming the channel, so the page is not silently dropped", async () => {
|
|
2674
|
+
/*
|
|
2675
|
+
* A dropped page with no trace is the failure this whole epic exists to
|
|
2676
|
+
* end. The timeline row is the surface both the responder and the
|
|
2677
|
+
* operator read, so the mismatch has to arrive there in words that point
|
|
2678
|
+
* at the rule to repair.
|
|
2679
|
+
*/
|
|
2680
|
+
stubs.ruleFindOneById.mockResolvedValue(
|
|
2681
|
+
loadedRule({
|
|
2682
|
+
ruleOwnerUserId: VICTIM_USER_ID,
|
|
2683
|
+
relationColumn: "userWebhook",
|
|
2684
|
+
methodOwnerUserId: ADMIN_USER_ID,
|
|
2685
|
+
}) as never,
|
|
2686
|
+
);
|
|
2687
|
+
|
|
2688
|
+
await UserNotificationRuleService.executeNotificationRuleItem(
|
|
2689
|
+
RULE_ID,
|
|
2690
|
+
executeOptions(),
|
|
2691
|
+
);
|
|
2692
|
+
|
|
2693
|
+
expect(timelineRows).toHaveLength(1);
|
|
2694
|
+
expect(timelineRows[0]!.status).toBe(UserNotificationStatus.Error);
|
|
2695
|
+
expect(timelineRows[0]!.statusMessage).toContain("Webhook");
|
|
2696
|
+
expect(timelineRows[0]!.statusMessage).toContain(
|
|
2697
|
+
"belongs to a different user",
|
|
2698
|
+
);
|
|
2699
|
+
expect(timelineRows[0]!.userId).toBe(VICTIM_USER_ID.toString());
|
|
2700
|
+
expect(timelineRows[0]!.userNotificationRuleId).toBe(RULE_ID.toString());
|
|
2701
|
+
expect(timelineRows[0]!.userNotificationLogId).toBe(
|
|
2702
|
+
ON_CALL_LOG_ID.toString(),
|
|
2703
|
+
);
|
|
2704
|
+
expect(timelineRows[0]!.isRoot).toBe(true);
|
|
2705
|
+
});
|
|
2706
|
+
|
|
2707
|
+
test("one foreign method poisons the whole rule, including the channels that are honest", async () => {
|
|
2708
|
+
/*
|
|
2709
|
+
* Delivering the untainted channels would let a hijacked row keep working
|
|
2710
|
+
* well enough that nobody investigates. The row is not a rule with one
|
|
2711
|
+
* bad field; it is a row somebody wrote in order to redirect a page.
|
|
2712
|
+
*/
|
|
2713
|
+
const rule: UserNotificationRule = loadedRule({
|
|
2714
|
+
ruleOwnerUserId: VICTIM_USER_ID,
|
|
2715
|
+
relationColumn: "userSms",
|
|
2716
|
+
methodOwnerUserId: ADMIN_USER_ID,
|
|
2717
|
+
});
|
|
2718
|
+
|
|
2719
|
+
(rule as unknown as Record<string, unknown>)["userEmail"] = {
|
|
2720
|
+
_id: VICTIM_METHOD_ID.toString(),
|
|
2721
|
+
isVerified: true,
|
|
2722
|
+
email: new Email(VICTIM_EMAIL),
|
|
2723
|
+
userId: VICTIM_USER_ID,
|
|
2724
|
+
};
|
|
2725
|
+
|
|
2726
|
+
stubs.ruleFindOneById.mockResolvedValue(rule as never);
|
|
2727
|
+
|
|
2728
|
+
await UserNotificationRuleService.executeNotificationRuleItem(
|
|
2729
|
+
RULE_ID,
|
|
2730
|
+
executeOptions(),
|
|
2731
|
+
);
|
|
2732
|
+
|
|
2733
|
+
expect(stubs.deliver).not.toHaveBeenCalled();
|
|
2734
|
+
expect(timelineRows[0]!.statusMessage).toContain("SMS");
|
|
2735
|
+
});
|
|
2736
|
+
|
|
2737
|
+
test("a rule whose method belongs to its own owner is delivered normally", async () => {
|
|
2738
|
+
const rule: UserNotificationRule = loadedRule({
|
|
2739
|
+
ruleOwnerUserId: VICTIM_USER_ID,
|
|
2740
|
+
relationColumn: "userEmail",
|
|
2741
|
+
methodOwnerUserId: VICTIM_USER_ID,
|
|
2742
|
+
});
|
|
2743
|
+
|
|
2744
|
+
stubs.ruleFindOneById.mockResolvedValue(rule as never);
|
|
2745
|
+
|
|
2746
|
+
await UserNotificationRuleService.executeNotificationRuleItem(
|
|
2747
|
+
RULE_ID,
|
|
2748
|
+
executeOptions(),
|
|
2749
|
+
);
|
|
2750
|
+
|
|
2751
|
+
expect(stubs.deliver).toHaveBeenCalledTimes(1);
|
|
2752
|
+
expect(timelineRows).toHaveLength(0);
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
test("a method whose owner column was never loaded is NOT read as a mismatch", async () => {
|
|
2756
|
+
/*
|
|
2757
|
+
* The most dangerous way to get this check wrong. An unselected column
|
|
2758
|
+
* arrives as undefined, and treating absence as disagreement would turn
|
|
2759
|
+
* the guard into a page-suppression machine for every caller that does
|
|
2760
|
+
* not select userId - which is the exact failure mode the guard was added
|
|
2761
|
+
* to prevent. Silence means "no evidence of a mismatch", and for a check
|
|
2762
|
+
* that can cancel a page that is the only safe default.
|
|
2763
|
+
*/
|
|
2764
|
+
const rule: UserNotificationRule = loadedRule({
|
|
2765
|
+
ruleOwnerUserId: VICTIM_USER_ID,
|
|
2766
|
+
relationColumn: "userEmail",
|
|
2767
|
+
methodOwnerUserId: undefined,
|
|
2768
|
+
});
|
|
2769
|
+
|
|
2770
|
+
stubs.ruleFindOneById.mockResolvedValue(rule as never);
|
|
2771
|
+
|
|
2772
|
+
await UserNotificationRuleService.executeNotificationRuleItem(
|
|
2773
|
+
RULE_ID,
|
|
2774
|
+
executeOptions(),
|
|
2775
|
+
);
|
|
2776
|
+
|
|
2777
|
+
expect(stubs.deliver).toHaveBeenCalledTimes(1);
|
|
2778
|
+
expect(timelineRows).toHaveLength(0);
|
|
2779
|
+
});
|
|
2780
|
+
|
|
2781
|
+
test("the rule load asks every method relation for its owner", async () => {
|
|
2782
|
+
/*
|
|
2783
|
+
* The check above is only as good as the select that feeds it: drop
|
|
2784
|
+
* userId from one relation and that channel silently becomes
|
|
2785
|
+
* un-checkable, which is indistinguishable from having no guard on it.
|
|
2786
|
+
*/
|
|
2787
|
+
stubs.ruleFindOneById.mockResolvedValue(
|
|
2788
|
+
loadedRule({
|
|
2789
|
+
ruleOwnerUserId: VICTIM_USER_ID,
|
|
2790
|
+
relationColumn: "userEmail",
|
|
2791
|
+
methodOwnerUserId: VICTIM_USER_ID,
|
|
2792
|
+
}) as never,
|
|
2793
|
+
);
|
|
2794
|
+
|
|
2795
|
+
await UserNotificationRuleService.executeNotificationRuleItem(
|
|
2796
|
+
RULE_ID,
|
|
2797
|
+
executeOptions(),
|
|
2798
|
+
);
|
|
2799
|
+
|
|
2800
|
+
const select: Record<string, Record<string, boolean>> = (
|
|
2801
|
+
stubs.ruleFindOneById.mock.calls[0]![0] as {
|
|
2802
|
+
select: Record<string, Record<string, boolean>>;
|
|
2803
|
+
}
|
|
2804
|
+
).select;
|
|
2805
|
+
|
|
2806
|
+
expect(select["userId"]).toBe(true);
|
|
2807
|
+
|
|
2808
|
+
for (const channel of CHANNELS) {
|
|
2809
|
+
expect(select[channel.relationColumn]!["userId"]).toBe(true);
|
|
2810
|
+
}
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2813
|
+
test("an unowned rule is still delivered - there is no owner to compare a method against", async () => {
|
|
2814
|
+
/*
|
|
2815
|
+
* Not an endorsement of unowned rows; simply that this guard has nothing
|
|
2816
|
+
* to say about them. Inventing a mismatch out of a missing owner would
|
|
2817
|
+
* drop pages for rows the create-path guard already refuses to write.
|
|
2818
|
+
*/
|
|
2819
|
+
const rule: UserNotificationRule = loadedRule({
|
|
2820
|
+
ruleOwnerUserId: undefined,
|
|
2821
|
+
relationColumn: "userEmail",
|
|
2822
|
+
methodOwnerUserId: ADMIN_USER_ID,
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
stubs.ruleFindOneById.mockResolvedValue(rule as never);
|
|
2826
|
+
|
|
2827
|
+
await UserNotificationRuleService.executeNotificationRuleItem(
|
|
2828
|
+
RULE_ID,
|
|
2829
|
+
executeOptions(),
|
|
2830
|
+
);
|
|
2831
|
+
|
|
2832
|
+
expect(stubs.deliver).toHaveBeenCalledTimes(1);
|
|
2833
|
+
});
|
|
2834
|
+
|
|
2835
|
+
test("the execution claim is still taken before any of this - the mismatch check must not become a second page", async () => {
|
|
2836
|
+
stubs.claim.mockResolvedValue(false as never);
|
|
2837
|
+
|
|
2838
|
+
await UserNotificationRuleService.executeNotificationRuleItem(
|
|
2839
|
+
RULE_ID,
|
|
2840
|
+
executeOptions(),
|
|
2841
|
+
);
|
|
2842
|
+
|
|
2843
|
+
expect(stubs.ruleFindOneById).not.toHaveBeenCalled();
|
|
2844
|
+
expect(stubs.deliver).not.toHaveBeenCalled();
|
|
2845
|
+
expect(timelineRows).toHaveLength(0);
|
|
2846
|
+
});
|
|
2847
|
+
});
|
|
2848
|
+
});
|