@oneuptime/common 12.0.8 → 12.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +1 -1
- package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +1 -1
- package/Models/DatabaseModels/AIConversation.ts +32 -0
- package/Models/DatabaseModels/AIConversationMessage.ts +41 -0
- package/Models/DatabaseModels/AlertEpisodeMember.ts +27 -0
- package/Models/DatabaseModels/IncidentEpisodeMember.ts +28 -0
- package/Models/DatabaseModels/Index.ts +12 -4
- package/Models/DatabaseModels/{TelemetryEntity.ts → InventoryItem.ts} +139 -9
- package/Models/DatabaseModels/InventoryItemCustomField.ts +434 -0
- package/Models/DatabaseModels/{TelemetryEntityRelationship.ts → InventoryItemRelationship.ts} +8 -8
- package/Models/DatabaseModels/NetworkDevice.ts +141 -0
- package/Models/DatabaseModels/NetworkDeviceLink.ts +699 -0
- package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +467 -0
- package/Models/DatabaseModels/NetworkTopologySuppression.ts +429 -0
- package/Models/DatabaseModels/OnCallDutyPolicyFeed.ts +9 -0
- package/Models/DatabaseModels/Project.ts +78 -0
- package/Models/DatabaseModels/UserCall.ts +42 -0
- package/Models/DatabaseModels/UserEmail.ts +44 -0
- package/Models/DatabaseModels/UserNotificationRule.ts +425 -55
- package/Models/DatabaseModels/UserOnCallLogTimeline.ts +24 -2
- package/Models/DatabaseModels/UserPush.ts +49 -0
- package/Models/DatabaseModels/UserSMS.ts +43 -0
- package/Models/DatabaseModels/UserTelegram.ts +59 -0
- package/Models/DatabaseModels/UserWebhook.ts +52 -0
- package/Models/DatabaseModels/UserWhatsApp.ts +41 -0
- package/Models/DatabaseModels/WorkflowVariable.ts +12 -0
- package/Server/API/AIChatAPI.ts +315 -1
- package/Server/API/DashboardAPI.ts +217 -1
- package/Server/API/OnCallReadinessAPI.ts +841 -0
- package/Server/API/TeamComplianceAPI.ts +69 -17
- package/Server/API/TelemetryAPI.ts +220 -12
- package/Server/EnvironmentConfig.ts +52 -0
- package/Server/Infrastructure/Postgres/DataSourceOptions.ts +22 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +4 -4
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +5 -5
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.ts +35 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.ts +82 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.ts +91 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.ts +47 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.ts +255 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.ts +107 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.ts +90 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.ts +39 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.ts +33 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.ts +59 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +20 -0
- package/Server/Infrastructure/Queue.ts +78 -13
- package/Server/Middleware/PublicDashboardRateLimit.ts +593 -0
- package/Server/Services/AIService.ts +7 -0
- package/Server/Services/AlertEpisodeStateTimelineService.ts +29 -0
- package/Server/Services/AlertSeverityService.ts +63 -0
- package/Server/Services/DashboardService.ts +9 -10
- package/Server/Services/DatabaseService.ts +32 -2
- package/Server/Services/IncidentEpisodeStateTimelineService.ts +29 -0
- package/Server/Services/IncidentSeverityService.ts +76 -0
- package/Server/Services/Index.ts +12 -4
- package/Server/Services/InventoryItemCustomFieldService.ts +9 -0
- package/Server/Services/{TelemetryEntityRelationshipService.ts → InventoryItemRelationshipService.ts} +4 -4
- package/Server/Services/{TelemetryEntityService.ts → InventoryItemService.ts} +89 -20
- package/Server/Services/LogAggregationService.ts +45 -8
- package/Server/Services/MetricAggregationService.ts +121 -0
- package/Server/Services/MetricService.ts +7 -7
- package/Server/Services/NetworkDeviceLinkRuleService.ts +10 -0
- package/Server/Services/NetworkDeviceLinkService.ts +84 -0
- package/Server/Services/NetworkDeviceService.ts +140 -0
- package/Server/Services/NetworkSiteService.ts +77 -25
- package/Server/Services/NetworkTopologySuppressionService.ts +84 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +41 -29
- package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +8 -0
- package/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.ts +62 -13
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +61 -1
- package/Server/Services/OnCallNotificationAlertingService.ts +742 -0
- package/Server/Services/OnCallReadinessService.ts +2803 -0
- package/Server/Services/OnCallSetupReminderService.ts +955 -0
- package/Server/Services/ProfileAggregationService.ts +123 -0
- package/Server/Services/StatusPageService.ts +9 -10
- package/Server/Services/TeamComplianceService.ts +429 -252
- package/Server/Services/UserCallService.ts +26 -1
- package/Server/Services/UserEmailService.ts +26 -1
- package/Server/Services/UserNotificationRuleAdminService.ts +1183 -0
- package/Server/Services/UserNotificationRuleService.ts +3812 -333
- package/Server/Services/UserOnCallLogService.ts +561 -48
- package/Server/Services/UserPushService.ts +29 -0
- package/Server/Services/UserService.ts +11 -0
- package/Server/Services/UserSmsService.ts +26 -1
- package/Server/Services/UserTelegramService.ts +24 -1
- package/Server/Services/UserWebhookService.ts +28 -1
- package/Server/Services/UserWhatsAppService.ts +24 -1
- package/Server/Types/Database/Permissions/BasePermission.ts +19 -0
- package/Server/Types/Database/Permissions/CreatePermission.ts +164 -0
- package/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.ts +340 -0
- package/Server/Types/Database/Permissions/QueryPermission.ts +48 -0
- package/Server/Types/Database/Permissions/TenantPermission.ts +8 -1
- package/Server/Types/Workflow/Components/API/Delete.ts +1 -1
- package/Server/Types/Workflow/Components/API/Get.ts +1 -1
- package/Server/Types/Workflow/Components/API/Patch.ts +1 -1
- package/Server/Types/Workflow/Components/API/Post.ts +1 -1
- package/Server/Types/Workflow/Components/API/Put.ts +1 -1
- package/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.ts +29 -5
- package/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.ts +18 -10
- package/Server/Types/Workflow/Components/BaseModel/ModelArguments.ts +55 -0
- package/Server/Types/Workflow/Components/Conditions/IfElse.ts +3 -17
- package/Server/Types/Workflow/Components/Email.ts +25 -7
- package/Server/Types/Workflow/Components/JavaScript.ts +10 -3
- package/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.ts +1 -1
- package/Server/Utils/AI/Chat/ChatAgentRunner.ts +643 -48
- package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +32 -3
- package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +20 -6
- package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +7 -0
- package/Server/Utils/AI/Toolbox/AIActionTools.ts +2 -2
- package/Server/Utils/AI/Toolbox/AIMetaTools.ts +863 -0
- package/Server/Utils/AI/Toolbox/AlertTools.ts +177 -15
- package/Server/Utils/AI/Toolbox/IncidentTools.ts +191 -10
- package/Server/Utils/AI/Toolbox/Index.ts +48 -0
- package/Server/Utils/AI/Toolbox/MonitorTools.ts +298 -11
- package/Server/Utils/AI/Toolbox/NoteWriteTools.ts +295 -0
- package/Server/Utils/AI/Toolbox/OnCallTools.ts +1246 -0
- package/Server/Utils/AI/Toolbox/RunbookTools.ts +424 -0
- package/Server/Utils/AI/Toolbox/SloTools.ts +456 -0
- package/Server/Utils/AI/Toolbox/StatusPageTools.ts +559 -0
- package/Server/Utils/AI/Toolbox/TeamTools.ts +327 -0
- package/Server/Utils/AI/Toolbox/TimelineTools.ts +615 -0
- package/Server/Utils/AI/Toolbox/WorkflowProbeTools.ts +664 -0
- package/Server/Utils/ClientIp.ts +221 -0
- package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +47 -0
- package/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.ts +163 -0
- package/Server/Utils/Dashboard/PublicDashboardSloWidget.ts +147 -0
- package/Server/Utils/Express.ts +12 -17
- package/Server/Utils/LLM/LLMService.ts +85 -8
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +204 -10
- package/Server/Utils/SSRFProtection.ts +98 -23
- package/Server/Utils/StartServer.ts +12 -3
- package/Server/Utils/Telemetry/EntityRegistry.ts +205 -18
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +25 -25
- package/Server/Utils/Telemetry/TelemetryEntity.ts +160 -52
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +7 -3
- package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +2146 -0
- package/Tests/App/Dashboard/CreateWorkflowModal.test.tsx +561 -0
- package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +2470 -0
- package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +1897 -0
- package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3606 -0
- package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +784 -0
- package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +1119 -0
- package/Tests/App/Dashboard/SloWidgetFetching.test.tsx +531 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +1312 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistPage.test.tsx +1390 -0
- package/Tests/Models/InventoryItemModel.test.ts +174 -0
- package/Tests/Models/InventoryItemNaming.test.ts +302 -0
- package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +437 -0
- package/Tests/Server/API/DashboardPublicRateLimit.test.ts +659 -0
- package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +18 -0
- package/Tests/Server/API/DashboardPublicSloAPI.test.ts +880 -0
- package/Tests/Server/API/Helpers.ts +24 -15
- package/Tests/Server/API/OnCallReadinessAPI.test.ts +2680 -0
- package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +915 -0
- package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +533 -0
- package/Tests/Server/Infrastructure/Postgres/InventoryItemArchiveMigration.test.ts +213 -0
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +432 -0
- package/Tests/Server/Infrastructure/Queue.test.ts +293 -0
- package/Tests/Server/Middleware/PublicDashboardRateLimit.test.ts +1645 -0
- package/Tests/Server/Services/AdminRuleEditGuards.test.ts +2848 -0
- package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1393 -0
- package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +1802 -0
- package/Tests/Server/Services/EpisodeStateTimelineNote.test.ts +304 -0
- package/Tests/Server/Services/InventoryItemDisplayName.test.ts +339 -0
- package/Tests/Server/Services/{TelemetryEntityManualCreate.test.ts → InventoryItemManualCreate.test.ts} +27 -27
- package/Tests/Server/Services/IpAllowlistSpoofing.test.ts +450 -0
- package/Tests/Server/Services/LogAggregationService.test.ts +235 -1
- package/Tests/Server/Services/MetricAggregationService.test.ts +231 -0
- package/Tests/Server/Services/MetricEntityMVKeyParity.test.ts +80 -29
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +30 -30
- package/Tests/Server/Services/NetworkSiteService.test.ts +18 -3
- package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +1728 -0
- package/Tests/Server/Services/NotificationDeletionImpact.test.ts +2402 -0
- package/Tests/Server/Services/OnCallDutyPolicyExecutionLogTimelineGapFeed.test.ts +394 -0
- package/Tests/Server/Services/OnCallNotificationFallback.test.ts +1744 -0
- package/Tests/Server/Services/OnCallReadinessService.test.ts +4295 -0
- package/Tests/Server/Services/OnCallSetupReminder.test.ts +1272 -0
- package/Tests/Server/Services/OnCallWeeklyReadinessDigest.test.ts +1021 -0
- package/Tests/Server/Services/ProfileAggregationService.test.ts +296 -0
- package/Tests/Server/Services/SeverityCreationRuleBackfill.test.ts +1536 -0
- package/Tests/Server/Services/SeverityRuleBackfill.test.ts +1818 -0
- package/Tests/Server/Services/TeamComplianceServiceBehaviour.test.ts +1845 -0
- package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +1394 -0
- package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +1166 -0
- package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1468 -0
- package/Tests/Server/Services/UserOnCallLogNoNotificationRules.test.ts +1457 -0
- package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +1546 -0
- package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +529 -0
- package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +1219 -0
- package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +1089 -0
- package/Tests/Server/Types/Workflow/Components/ApiComponentErrorPort.test.ts +2 -1
- package/Tests/Server/Types/Workflow/Components/BaseModelDatabaseComponents.test.ts +190 -0
- package/Tests/Server/Types/Workflow/Components/ChatWebhookComponents.test.ts +44 -14
- package/Tests/Server/Types/Workflow/Components/Email.test.ts +151 -0
- package/Tests/Server/Types/Workflow/Components/IfElse.test.ts +98 -0
- package/Tests/Server/Types/Workflow/Components/JavaScript.test.ts +51 -0
- package/Tests/Server/Utils/AI/AIMetaTools.test.ts +586 -0
- package/Tests/Server/Utils/AI/AlertMonitorFilters.test.ts +582 -0
- package/Tests/Server/Utils/AI/ChatAgentRunner.test.ts +726 -0
- package/Tests/Server/Utils/AI/IncidentToolsFilters.test.ts +315 -0
- package/Tests/Server/Utils/AI/LLMServiceStopReason.test.ts +314 -0
- package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +26 -3
- package/Tests/Server/Utils/AI/NoteWriteTools.test.ts +268 -0
- package/Tests/Server/Utils/AI/ObservabilityChatPrompt.test.ts +169 -0
- package/Tests/Server/Utils/AI/OnCallTools.test.ts +664 -0
- package/Tests/Server/Utils/AI/RunbookTools.test.ts +325 -0
- package/Tests/Server/Utils/AI/SloTools.test.ts +306 -0
- package/Tests/Server/Utils/AI/StatusPageTools.test.ts +391 -0
- package/Tests/Server/Utils/AI/TeamTools.test.ts +257 -0
- package/Tests/Server/Utils/AI/TimelineTools.test.ts +472 -0
- package/Tests/Server/Utils/AI/WorkflowProbeTools.test.ts +428 -0
- package/Tests/Server/Utils/ClientIp.test.ts +438 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +171 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.test.ts +383 -0
- package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +23 -25
- package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +6 -0
- package/Tests/Server/Utils/Monitor/Criteria/DnssecMonitorCriteria.test.ts +307 -0
- package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +468 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorTelemetryDeepLinks.test.ts +460 -0
- package/Tests/Server/Utils/ResponseRateLimitStatusCodes.test.ts +137 -0
- package/Tests/Server/Utils/SSRFProtectionBypasses.test.ts +40 -8
- package/Tests/Server/Utils/SSRFProtectionUserInfo.test.ts +351 -0
- package/Tests/Server/Utils/Telemetry/EntityRegistryRetirement.test.ts +531 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +15 -15
- package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +356 -39
- package/Tests/Types/IP/IP.test.ts +263 -0
- package/Tests/Types/IP/IPWhitelist.test.ts +197 -0
- package/Tests/Types/IP/IPv6.test.ts +12 -1
- package/Tests/Types/Monitor/SnmpOid.test.ts +64 -0
- package/Tests/Types/NetworkDevice/NetworkDeviceMonitoringMethod.test.ts +110 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +106 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeDifferential.test.ts +511 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageEndToEnd.test.ts +489 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageGapTolerance.test.ts +479 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageState.test.ts +822 -0
- package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +6 -6
- package/Tests/Types/Workflow/BaseModelComponents.test.ts +429 -0
- package/Tests/Types/Workflow/Components/BaseModel.test.ts +225 -0
- package/Tests/Types/Workflow/IntegrationCredentialMetadata.test.ts +100 -0
- package/Tests/Types/Workflow/TemplateSyntax.test.ts +112 -0
- package/Tests/Types/Workflow/Templates.test.ts +916 -101
- package/Tests/UI/Components/ActiveFilterChipsOpenRoute.test.tsx +82 -0
- package/Tests/UI/Components/ComponentsModal.test.tsx +564 -7
- package/Tests/UI/Components/KeyboardShortcut.test.tsx +95 -0
- package/Tests/UI/Components/LogDetailsPanelCrossSignal.test.tsx +448 -0
- package/Tests/UI/Components/LogsTableCrossLinks.test.tsx +263 -0
- package/Tests/UI/Components/PendingProjectInvitations.test.tsx +913 -0
- package/Tests/UI/Components/SimpleLogViewer.test.tsx +228 -0
- package/Tests/UI/Components/TableRowSelectability.test.tsx +312 -0
- package/Tests/UI/Components/Workflow/GraphLint.test.ts +72 -0
- package/Tests/UI/Components/Workflow/GraphLintSummary.test.ts +755 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +242 -7
- package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +15 -7
- package/Tests/UI/Components/Workflow/ModelSchema.test.ts +242 -28
- package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +59 -1
- package/Tests/UI/Components/Workflow/StepTraceViewer.test.tsx +256 -0
- package/Tests/UI/Components/Workflow/UseRunWatch.test.tsx +665 -0
- package/Tests/UI/Components/Workflow/WorkflowIssuesModal.test.tsx +485 -0
- package/Tests/UI/Components/Workflow/WorkflowLogModal.test.tsx +478 -0
- package/Tests/UI/Components/Workflow/WorkflowStatusBar.test.tsx +379 -0
- package/Tests/UI/EsbuildConfig.test.ts +607 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +11 -4
- package/Tests/UI/Utils/ModelAPICreateMiscData.test.ts +94 -0
- package/Tests/UI/Utils/Platform.test.ts +147 -0
- package/Tests/UI/Utils/ProjectInvitationDisplay.test.ts +357 -0
- package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +198 -0
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +487 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +698 -0
- package/Tests/__mocks__/bullmq.js +55 -0
- package/Types/AI/AIChatMessageStatus.ts +8 -1
- package/Types/AI/AIChatTypes.ts +12 -0
- package/Types/Database/AccessControl/OwnerOnlyColumn.ts +88 -0
- package/Types/Exception/ExceptionCode.ts +2 -0
- package/Types/Exception/ServiceUnavailableException.ts +8 -0
- package/Types/Exception/TooManyRequestsException.ts +8 -0
- package/Types/IP/IP.ts +93 -47
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +49 -3
- package/Types/NetworkDevice/NetworkDeviceMonitoringMethod.ts +55 -0
- package/Types/OnCallDutyPolicy/Layer.ts +203 -149
- package/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.ts +13 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +155 -10
- package/Types/Permission.ts +193 -0
- package/Types/Telemetry/EntityRelationshipType.ts +1 -1
- package/Types/Telemetry/EntitySource.ts +1 -1
- package/Types/Telemetry/EntityType.ts +1 -1
- package/Types/Telemetry/EntityTypeGroups.ts +1 -1
- package/Types/Workflow/Components/BaseModel.ts +75 -29
- package/Types/Workflow/Components/Discord.ts +1 -0
- package/Types/Workflow/Components/Email.ts +12 -3
- package/Types/Workflow/Components/JavaScript.ts +7 -0
- package/Types/Workflow/Components/MicrosoftTeams.ts +3 -2
- package/Types/Workflow/Components/Slack.ts +1 -0
- package/Types/Workflow/Components/Telegram.ts +1 -0
- package/Types/Workflow/TemplateSyntax.ts +44 -0
- package/Types/Workflow/Templates.ts +2097 -45
- package/UI/Components/Calendar/Calendar.css +43 -0
- package/UI/Components/Calendar/Calendar.tsx +8 -0
- package/UI/Components/Card/Card.tsx +2 -2
- package/UI/Components/Checkbox/Checkbox.tsx +16 -0
- package/UI/Components/Dictionary/Dictionary.tsx +48 -9
- package/UI/Components/FormModal/BasicFormModal.tsx +2 -1
- package/UI/Components/Header/HeaderIconDropdownButton.tsx +53 -3
- package/UI/Components/Input/Input.tsx +1 -0
- package/UI/Components/KeyboardShortcut/KeyboardKey.ts +185 -0
- package/UI/Components/KeyboardShortcut/KeyboardShortcut.tsx +87 -0
- package/UI/Components/LogsViewer/LogsViewer.tsx +28 -0
- package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +31 -0
- package/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.tsx +18 -18
- package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +363 -14
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +11 -0
- package/UI/Components/LogsViewer/components/LogsTable.tsx +155 -12
- package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +29 -0
- package/UI/Components/LogsViewer/types.ts +23 -0
- package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +9 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +15 -30
- package/UI/Components/ProjectInvitations/PendingProjectInvitations.tsx +442 -0
- package/UI/Components/SimpleLogViewer/SimpleLogViewer.tsx +23 -1
- package/UI/Components/Table/Table.tsx +49 -16
- package/UI/Components/Table/TableBody.tsx +53 -28
- package/UI/Components/Table/TableHeader.tsx +13 -0
- package/UI/Components/Table/TableRow.tsx +58 -26
- package/UI/Components/Workflow/ArgumentsForm.tsx +155 -9
- package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +26 -0
- package/UI/Components/Workflow/ComponentSettingsModal.tsx +10 -1
- package/UI/Components/Workflow/ComponentValuePickerModal.tsx +135 -7
- package/UI/Components/Workflow/ComponentsModal.tsx +116 -22
- package/UI/Components/Workflow/DocumentationViewer.tsx +59 -9
- package/UI/Components/Workflow/GraphLint.ts +42 -5
- package/UI/Components/Workflow/GraphLintSummary.ts +390 -0
- package/UI/Components/Workflow/ModelColumnEditor.tsx +154 -22
- package/UI/Components/Workflow/ModelSchema.ts +115 -33
- package/UI/Components/Workflow/RunStatusWatcher.ts +1 -1
- package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
- package/UI/Components/Workflow/UseRunWatch.ts +212 -0
- package/UI/Components/Workflow/VariableModal.tsx +6 -2
- package/UI/Components/Workflow/Workflow.tsx +49 -3
- package/UI/Components/Workflow/WorkflowIssuesModal.tsx +255 -0
- package/UI/Components/Workflow/WorkflowLogModal.tsx +128 -0
- package/UI/Components/Workflow/WorkflowStatusBar.tsx +224 -0
- package/UI/Utils/AIChatExport/ConversationMarkdown.ts +10 -0
- package/UI/Utils/ModelAPI/ModelAPI.ts +7 -1
- package/UI/Utils/Platform.ts +149 -0
- package/UI/Utils/ProjectInvitationDisplay.ts +118 -0
- package/UI/esbuild-config.js +22 -1
- package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +187 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +888 -164
- package/Utils/Telemetry/CrossSignalScope.ts +502 -0
- package/Utils/Telemetry/EntityKey.ts +5 -5
- package/Utils/Telemetry/EntityRelationship.ts +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +1 -1
- package/build/dist/Models/DatabaseModels/AIConversation.js +32 -0
- package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AIConversationMessage.js +42 -0
- package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +28 -0
- package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +29 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +12 -4
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/{TelemetryEntity.js → InventoryItem.js} +167 -31
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -0
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js +454 -0
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js.map +1 -0
- package/build/dist/Models/DatabaseModels/{TelemetryEntityRelationship.js → InventoryItemRelationship.js} +27 -27
- package/build/dist/Models/DatabaseModels/InventoryItemRelationship.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +141 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +719 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +475 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js +446 -0
- package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js.map +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js +9 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +80 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserCall.js +46 -2
- package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserEmail.js +48 -2
- package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserNotificationRule.js +424 -55
- package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +24 -2
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserPush.js +51 -1
- package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserSMS.js +47 -2
- package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTelegram.js +65 -3
- package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWebhook.js +56 -2
- package/build/dist/Models/DatabaseModels/UserWebhook.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js +45 -2
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkflowVariable.js +12 -0
- package/build/dist/Models/DatabaseModels/WorkflowVariable.js.map +1 -1
- package/build/dist/Server/API/AIChatAPI.js +237 -1
- package/build/dist/Server/API/AIChatAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +165 -13
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/OnCallReadinessAPI.js +599 -0
- package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -0
- package/build/dist/Server/API/TeamComplianceAPI.js +68 -9
- package/build/dist/Server/API/TeamComplianceAPI.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +129 -18
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +45 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +22 -0
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +4 -4
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js +38 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js +22 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js +150 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js +57 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js +69 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js +32 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js +48 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/Queue.js +72 -13
- package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
- package/build/dist/Server/Middleware/PublicDashboardRateLimit.js +399 -0
- package/build/dist/Server/Middleware/PublicDashboardRateLimit.js.map +1 -0
- package/build/dist/Server/Services/AIService.js +1 -0
- package/build/dist/Server/Services/AIService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js +24 -3
- package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/AlertSeverityService.js +54 -0
- package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
- package/build/dist/Server/Services/DashboardService.js +10 -9
- package/build/dist/Server/Services/DashboardService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +24 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +24 -3
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentSeverityService.js +67 -0
- package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +12 -4
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemCustomFieldService.js +9 -0
- package/build/dist/Server/Services/InventoryItemCustomFieldService.js.map +1 -0
- package/build/dist/Server/Services/{TelemetryEntityRelationshipService.js → InventoryItemRelationshipService.js} +6 -6
- package/build/dist/Server/Services/InventoryItemRelationshipService.js.map +1 -0
- package/build/dist/Server/Services/{TelemetryEntityService.js → InventoryItemService.js} +68 -23
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -0
- package/build/dist/Server/Services/LogAggregationService.js +27 -8
- package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
- package/build/dist/Server/Services/MetricAggregationService.js +80 -0
- package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +6 -6
- package/build/dist/Server/Services/MetricService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js +9 -0
- package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceLinkService.js +71 -0
- package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceService.js +113 -0
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/NetworkSiteService.js +53 -13
- package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
- package/build/dist/Server/Services/NetworkTopologySuppressionService.js +85 -0
- package/build/dist/Server/Services/NetworkTopologySuppressionService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +48 -32
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +8 -0
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js +51 -12
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +57 -13
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallNotificationAlertingService.js +548 -0
- package/build/dist/Server/Services/OnCallNotificationAlertingService.js.map +1 -0
- package/build/dist/Server/Services/OnCallReadinessService.js +1961 -0
- package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -0
- package/build/dist/Server/Services/OnCallSetupReminderService.js +738 -0
- package/build/dist/Server/Services/OnCallSetupReminderService.js.map +1 -0
- package/build/dist/Server/Services/ProfileAggregationService.js +68 -4
- package/build/dist/Server/Services/ProfileAggregationService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +11 -10
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/TeamComplianceService.js +312 -160
- package/build/dist/Server/Services/TeamComplianceService.js.map +1 -1
- package/build/dist/Server/Services/UserCallService.js +24 -1
- package/build/dist/Server/Services/UserCallService.js.map +1 -1
- package/build/dist/Server/Services/UserEmailService.js +24 -1
- package/build/dist/Server/Services/UserEmailService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleAdminService.js +858 -0
- package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -0
- package/build/dist/Server/Services/UserNotificationRuleService.js +2830 -175
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallLogService.js +488 -43
- package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
- package/build/dist/Server/Services/UserPushService.js +26 -0
- package/build/dist/Server/Services/UserPushService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +10 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserSmsService.js +24 -1
- package/build/dist/Server/Services/UserSmsService.js.map +1 -1
- package/build/dist/Server/Services/UserTelegramService.js +22 -1
- package/build/dist/Server/Services/UserTelegramService.js.map +1 -1
- package/build/dist/Server/Services/UserWebhookService.js +25 -1
- package/build/dist/Server/Services/UserWebhookService.js.map +1 -1
- package/build/dist/Server/Services/UserWhatsAppService.js +22 -1
- package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js +12 -1
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/CreatePermission.js +126 -0
- package/build/dist/Server/Types/Database/Permissions/CreatePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js +254 -0
- package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js.map +1 -0
- package/build/dist/Server/Types/Database/Permissions/QueryPermission.js +47 -2
- package/build/dist/Server/Types/Database/Permissions/QueryPermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +7 -0
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Delete.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Delete.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Get.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Get.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Patch.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Patch.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Post.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Post.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Put.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Put.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js +21 -5
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js +14 -10
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js +31 -0
- package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +3 -9
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Email.js +15 -4
- package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js +7 -2
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +514 -56
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +20 -3
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +19 -6
- package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +7 -0
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js +2 -2
- package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js +692 -0
- package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +148 -12
- package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +157 -10
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/Index.js +37 -0
- package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +259 -14
- package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js +235 -0
- package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js +1000 -0
- package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js +356 -0
- package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +394 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js +465 -0
- package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js +280 -0
- package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js +527 -0
- package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js +548 -0
- package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js.map +1 -0
- package/build/dist/Server/Utils/ClientIp.js +137 -0
- package/build/dist/Server/Utils/ClientIp.js.map +1 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +38 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js +89 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js.map +1 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js +77 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js.map +1 -0
- package/build/dist/Server/Utils/Express.js +12 -12
- package/build/dist/Server/Utils/Express.js.map +1 -1
- package/build/dist/Server/Utils/LLM/LLMService.js +70 -7
- package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +121 -11
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/SSRFProtection.js +82 -21
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +12 -4
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +165 -18
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +11 -11
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +122 -47
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +7 -3
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/AI/AIChatMessageStatus.js +8 -1
- package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -1
- package/build/dist/Types/AI/AIChatTypes.js +12 -0
- package/build/dist/Types/AI/AIChatTypes.js.map +1 -1
- package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js +60 -0
- package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js.map +1 -0
- package/build/dist/Types/Exception/ExceptionCode.js +2 -0
- package/build/dist/Types/Exception/ExceptionCode.js.map +1 -1
- package/build/dist/Types/Exception/ServiceUnavailableException.js +8 -0
- package/build/dist/Types/Exception/ServiceUnavailableException.js.map +1 -0
- package/build/dist/Types/Exception/TooManyRequestsException.js +8 -0
- package/build/dist/Types/Exception/TooManyRequestsException.js.map +1 -0
- package/build/dist/Types/IP/IP.js +87 -43
- package/build/dist/Types/IP/IP.js.map +1 -1
- package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js +50 -0
- package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +186 -123
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js +13 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +105 -11
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/Permission.js +174 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityRelationshipType.js +1 -1
- package/build/dist/Types/Telemetry/EntitySource.js +1 -1
- package/build/dist/Types/Telemetry/EntityType.js +1 -1
- package/build/dist/Types/Telemetry/EntityTypeGroups.js +1 -1
- package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -1
- package/build/dist/Types/Workflow/Components/BaseModel.js +66 -28
- package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Discord.js +1 -0
- package/build/dist/Types/Workflow/Components/Discord.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Email.js +12 -3
- package/build/dist/Types/Workflow/Components/Email.js.map +1 -1
- package/build/dist/Types/Workflow/Components/JavaScript.js +7 -0
- package/build/dist/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Types/Workflow/Components/MicrosoftTeams.js +3 -2
- package/build/dist/Types/Workflow/Components/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Slack.js +1 -0
- package/build/dist/Types/Workflow/Components/Slack.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Telegram.js +1 -0
- package/build/dist/Types/Workflow/Components/Telegram.js.map +1 -1
- package/build/dist/Types/Workflow/TemplateSyntax.js +12 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -1
- package/build/dist/Types/Workflow/Templates.js +1931 -38
- package/build/dist/Types/Workflow/Templates.js.map +1 -1
- package/build/dist/UI/Components/Calendar/Calendar.js +1 -1
- package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
- package/build/dist/UI/Components/Checkbox/Checkbox.js +1 -1
- package/build/dist/UI/Components/Checkbox/Checkbox.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/Dictionary.js +25 -11
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +27 -4
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +1 -0
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js +163 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js.map +1 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js +47 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js.map +1 -0
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +4 -4
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +11 -1
- package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js +10 -8
- package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +227 -14
- package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +5 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +69 -5
- package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +8 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +9 -2
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +8 -21
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js +251 -0
- package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js.map +1 -0
- package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js +9 -2
- package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js.map +1 -1
- package/build/dist/UI/Components/Table/Table.js +27 -15
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Components/Table/TableBody.js +24 -18
- package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +9 -1
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Table/TableRow.js +29 -21
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +130 -12
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js +10 -1
- package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +4 -4
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js +57 -7
- package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentsModal.js +53 -18
- package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/DocumentationViewer.js +19 -6
- package/build/dist/UI/Components/Workflow/DocumentationViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/GraphLint.js +33 -4
- package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -1
- package/build/dist/UI/Components/Workflow/GraphLintSummary.js +231 -0
- package/build/dist/UI/Components/Workflow/GraphLintSummary.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +116 -21
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ModelSchema.js +72 -34
- package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -1
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +1 -1
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -1
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/UseRunWatch.js +123 -0
- package/build/dist/UI/Components/Workflow/UseRunWatch.js.map +1 -0
- package/build/dist/UI/Components/Workflow/VariableModal.js +3 -2
- package/build/dist/UI/Components/Workflow/VariableModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/Workflow.js +41 -1
- package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
- package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js +99 -0
- package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js.map +1 -0
- package/build/dist/UI/Components/Workflow/WorkflowLogModal.js +56 -0
- package/build/dist/UI/Components/Workflow/WorkflowLogModal.js.map +1 -0
- package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js +92 -0
- package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js.map +1 -0
- package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js +9 -0
- package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
- package/build/dist/UI/Utils/Platform.js +118 -0
- package/build/dist/UI/Utils/Platform.js.map +1 -0
- package/build/dist/UI/Utils/ProjectInvitationDisplay.js +106 -0
- package/build/dist/UI/Utils/ProjectInvitationDisplay.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +108 -0
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +642 -136
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +328 -0
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -0
- package/build/dist/Utils/Telemetry/EntityKey.js +5 -5
- package/build/dist/Utils/Telemetry/EntityRelationship.js +1 -1
- package/jest.config.json +1 -0
- package/package.json +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +0 -1
- package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +0 -1
- package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +0 -1
- package/build/dist/Server/Services/TelemetryEntityService.js.map +0 -1
|
@@ -0,0 +1,1645 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
jest,
|
|
8
|
+
} from "@jest/globals";
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* The rate limiter guarding the anonymous /public-dashboard-api surface.
|
|
12
|
+
*
|
|
13
|
+
* The interesting behaviour here is not "does it count" — it is the handful
|
|
14
|
+
* of properties that decide whether the limit can be walked around at all:
|
|
15
|
+
* that the client address is taken from the hop OUR proxy wrote rather than
|
|
16
|
+
* the one the caller can forge, that rotating the dashboard id does not buy a
|
|
17
|
+
* fresh allowance, that the window does not slide forward under sustained
|
|
18
|
+
* load, and that the two buckets fail in opposite directions when Redis is
|
|
19
|
+
* gone. Each of those has its own describe block below.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
jest.mock("../../../Server/Infrastructure/Redis", () => {
|
|
23
|
+
return {
|
|
24
|
+
__esModule: true,
|
|
25
|
+
default: {
|
|
26
|
+
getClient: jest.fn(),
|
|
27
|
+
isConnected: jest.fn(),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
jest.mock("../../../Server/Utils/Logger", () => {
|
|
33
|
+
return {
|
|
34
|
+
__esModule: true,
|
|
35
|
+
default: {
|
|
36
|
+
debug: jest.fn(),
|
|
37
|
+
info: jest.fn(),
|
|
38
|
+
warn: jest.fn(),
|
|
39
|
+
error: jest.fn(),
|
|
40
|
+
},
|
|
41
|
+
getLogAttributesFromRequest: jest.fn().mockReturnValue({}),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
jest.mock("../../../Server/Utils/Response", () => {
|
|
46
|
+
return {
|
|
47
|
+
__esModule: true,
|
|
48
|
+
default: {
|
|
49
|
+
sendErrorResponse: jest.fn(),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
import Redis from "../../../Server/Infrastructure/Redis";
|
|
55
|
+
import Response from "../../../Server/Utils/Response";
|
|
56
|
+
import logger from "../../../Server/Utils/Logger";
|
|
57
|
+
import PublicDashboardRateLimit, {
|
|
58
|
+
PublicDashboardRateLimitBucket,
|
|
59
|
+
PublicDashboardRateLimitDecision,
|
|
60
|
+
PublicDashboardRateLimitOutcome,
|
|
61
|
+
PublicDashboardRateLimitScope,
|
|
62
|
+
} from "../../../Server/Middleware/PublicDashboardRateLimit";
|
|
63
|
+
import ExceptionCode from "../../../Types/Exception/ExceptionCode";
|
|
64
|
+
import Exception from "../../../Types/Exception/Exception";
|
|
65
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
66
|
+
import {
|
|
67
|
+
ExpressRequest,
|
|
68
|
+
ExpressResponse,
|
|
69
|
+
NextFunction,
|
|
70
|
+
} from "../../../Server/Utils/Express";
|
|
71
|
+
|
|
72
|
+
type MockedFn = ReturnType<typeof jest.fn>;
|
|
73
|
+
|
|
74
|
+
const getClientMock: MockedFn = Redis.getClient as unknown as MockedFn;
|
|
75
|
+
const isConnectedMock: MockedFn = Redis.isConnected as unknown as MockedFn;
|
|
76
|
+
const sendErrorResponseMock: MockedFn =
|
|
77
|
+
Response.sendErrorResponse as unknown as MockedFn;
|
|
78
|
+
const loggerWarnMock: MockedFn = logger.warn as unknown as MockedFn;
|
|
79
|
+
const loggerErrorMock: MockedFn = logger.error as unknown as MockedFn;
|
|
80
|
+
|
|
81
|
+
/* Defaults baked into the limiter; asserted directly in one test below. */
|
|
82
|
+
const READ_WINDOW_SECONDS: number = 60;
|
|
83
|
+
const READ_PER_DASHBOARD_LIMIT: number = 600;
|
|
84
|
+
const READ_PER_IP_LIMIT: number = 1800;
|
|
85
|
+
const MASTER_PASSWORD_WINDOW_SECONDS: number = 15 * 60;
|
|
86
|
+
const MASTER_PASSWORD_PER_DASHBOARD_LIMIT: number = 15;
|
|
87
|
+
const MASTER_PASSWORD_PER_IP_LIMIT: number = 45;
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* A fake that behaves like a Redis counter store rather than a bag of
|
|
91
|
+
* assertions: INCR really increments and EXPIRE really records a TTL, so the
|
|
92
|
+
* window/reset/pinning tests exercise the limiter's arithmetic instead of
|
|
93
|
+
* restating it.
|
|
94
|
+
*/
|
|
95
|
+
interface RecordedExpire {
|
|
96
|
+
key: string;
|
|
97
|
+
ttlSeconds: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class FakeRedisClient {
|
|
101
|
+
public counters: Map<string, number> = new Map();
|
|
102
|
+
public expires: Array<RecordedExpire> = [];
|
|
103
|
+
|
|
104
|
+
/* Set to make the next exec() reject, for the error-path tests. */
|
|
105
|
+
public failNextExec: Error | null = null;
|
|
106
|
+
|
|
107
|
+
/* Set to make the next exec() resolve to something malformed. */
|
|
108
|
+
public malformedExecResult: unknown | undefined = undefined;
|
|
109
|
+
|
|
110
|
+
public incrCallCount: number = 0;
|
|
111
|
+
|
|
112
|
+
public pipeline(): FakePipeline {
|
|
113
|
+
return new FakePipeline(this);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public reset(): void {
|
|
117
|
+
this.counters.clear();
|
|
118
|
+
this.expires = [];
|
|
119
|
+
this.failNextExec = null;
|
|
120
|
+
this.malformedExecResult = undefined;
|
|
121
|
+
this.incrCallCount = 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public expiresForKey(key: string): Array<RecordedExpire> {
|
|
125
|
+
return this.expires.filter((recorded: RecordedExpire) => {
|
|
126
|
+
return recorded.key === key;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type QueuedCommand = () => [Error | null, unknown];
|
|
132
|
+
|
|
133
|
+
class FakePipeline {
|
|
134
|
+
private commands: Array<QueuedCommand> = [];
|
|
135
|
+
|
|
136
|
+
public constructor(private client: FakeRedisClient) {}
|
|
137
|
+
|
|
138
|
+
public incr(key: string): FakePipeline {
|
|
139
|
+
this.commands.push((): [Error | null, unknown] => {
|
|
140
|
+
this.client.incrCallCount++;
|
|
141
|
+
const next: number = (this.client.counters.get(key) || 0) + 1;
|
|
142
|
+
this.client.counters.set(key, next);
|
|
143
|
+
return [null, next];
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public expire(key: string, ttlSeconds: number): FakePipeline {
|
|
150
|
+
this.commands.push((): [Error | null, unknown] => {
|
|
151
|
+
this.client.expires.push({ key, ttlSeconds });
|
|
152
|
+
return [null, 1];
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public async exec(): Promise<unknown> {
|
|
159
|
+
if (this.client.failNextExec) {
|
|
160
|
+
const error: Error = this.client.failNextExec;
|
|
161
|
+
this.client.failNextExec = null;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (typeof this.client.malformedExecResult !== "undefined") {
|
|
166
|
+
const result: unknown = this.client.malformedExecResult;
|
|
167
|
+
this.client.malformedExecResult = undefined;
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return this.commands.map((command: QueuedCommand) => {
|
|
172
|
+
return command();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface BuildRequestOverrides {
|
|
178
|
+
params?: Record<string, string>;
|
|
179
|
+
body?: Record<string, unknown>;
|
|
180
|
+
headers?: Record<string, string | Array<string>>;
|
|
181
|
+
socketAddress?: string | undefined;
|
|
182
|
+
ip?: string | undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const buildRequest: (overrides?: BuildRequestOverrides) => ExpressRequest = (
|
|
186
|
+
overrides: BuildRequestOverrides = {},
|
|
187
|
+
) => {
|
|
188
|
+
return {
|
|
189
|
+
params: overrides.params || {},
|
|
190
|
+
body: overrides.body,
|
|
191
|
+
headers: overrides.headers || {},
|
|
192
|
+
socket: { remoteAddress: overrides.socketAddress },
|
|
193
|
+
ip: overrides.ip,
|
|
194
|
+
} as unknown as ExpressRequest;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
interface BuiltResponse {
|
|
198
|
+
response: ExpressResponse;
|
|
199
|
+
headers: Record<string, string>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const buildResponse: () => BuiltResponse = () => {
|
|
203
|
+
const headers: Record<string, string> = {};
|
|
204
|
+
|
|
205
|
+
const response: ExpressResponse = {
|
|
206
|
+
setHeader: (name: string, value: string): void => {
|
|
207
|
+
headers[name] = value;
|
|
208
|
+
},
|
|
209
|
+
} as unknown as ExpressResponse;
|
|
210
|
+
|
|
211
|
+
return { response, headers };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
describe("PublicDashboardRateLimit", () => {
|
|
215
|
+
let client: FakeRedisClient;
|
|
216
|
+
let nowSpy: ReturnType<typeof jest.spyOn>;
|
|
217
|
+
let currentTime: number;
|
|
218
|
+
|
|
219
|
+
beforeEach(() => {
|
|
220
|
+
jest.clearAllMocks();
|
|
221
|
+
|
|
222
|
+
client = new FakeRedisClient();
|
|
223
|
+
getClientMock.mockReturnValue(client);
|
|
224
|
+
isConnectedMock.mockReturnValue(true);
|
|
225
|
+
|
|
226
|
+
/*
|
|
227
|
+
* Pinned to the start of a window so counters and Retry-After maths are
|
|
228
|
+
* deterministic. Tests that care about window boundaries move it.
|
|
229
|
+
*/
|
|
230
|
+
currentTime = 1_700_000_000_000;
|
|
231
|
+
currentTime = currentTime - (currentTime % (READ_WINDOW_SECONDS * 1000));
|
|
232
|
+
nowSpy = jest.spyOn(Date, "now").mockImplementation(() => {
|
|
233
|
+
return currentTime;
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
nowSpy.mockRestore();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const consumeRead: (data: {
|
|
242
|
+
dashboardKey?: string;
|
|
243
|
+
clientIp?: string;
|
|
244
|
+
}) => Promise<PublicDashboardRateLimitDecision> = (data: {
|
|
245
|
+
dashboardKey?: string;
|
|
246
|
+
clientIp?: string;
|
|
247
|
+
}) => {
|
|
248
|
+
return PublicDashboardRateLimit.consume({
|
|
249
|
+
dashboardKey: data.dashboardKey || "id:dashboard-a",
|
|
250
|
+
clientIp: data.clientIp || "203.0.113.7",
|
|
251
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
describe("resolveClientIp", () => {
|
|
256
|
+
/*
|
|
257
|
+
* The security-critical one. Nginx uses $proxy_add_x_forwarded_for, which
|
|
258
|
+
* APPENDS the peer address to whatever the caller sent — so a caller that
|
|
259
|
+
* sends its own X-Forwarded-For header controls the LEFT of the list. A
|
|
260
|
+
* limiter that keys on the leftmost entry can be defeated with a header,
|
|
261
|
+
* one fresh bucket per request.
|
|
262
|
+
*/
|
|
263
|
+
it("bills the hop our own proxy appended, not the one the caller forged", () => {
|
|
264
|
+
const request: ExpressRequest = buildRequest({
|
|
265
|
+
headers: { "x-forwarded-for": "9.9.9.9, 203.0.113.7" },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(PublicDashboardRateLimit.resolveClientIp(request)).toBe(
|
|
269
|
+
"203.0.113.7",
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("is unaffected by however many entries the caller forges", () => {
|
|
274
|
+
const forged: string = Array.from(
|
|
275
|
+
{ length: 50 },
|
|
276
|
+
(_unused: unknown, index: number) => {
|
|
277
|
+
return `10.0.0.${index}`;
|
|
278
|
+
},
|
|
279
|
+
).join(", ");
|
|
280
|
+
|
|
281
|
+
const request: ExpressRequest = buildRequest({
|
|
282
|
+
headers: { "x-forwarded-for": `${forged}, 203.0.113.7` },
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(PublicDashboardRateLimit.resolveClientIp(request)).toBe(
|
|
286
|
+
"203.0.113.7",
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("gives two forging callers from the same real address the same bucket", () => {
|
|
291
|
+
const first: string = PublicDashboardRateLimit.resolveClientIp(
|
|
292
|
+
buildRequest({
|
|
293
|
+
headers: { "x-forwarded-for": "1.1.1.1, 203.0.113.7" },
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const second: string = PublicDashboardRateLimit.resolveClientIp(
|
|
298
|
+
buildRequest({
|
|
299
|
+
headers: { "x-forwarded-for": "2.2.2.2, 203.0.113.7" },
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(first).toBe(second);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("uses the only entry when the caller forged nothing", () => {
|
|
307
|
+
const request: ExpressRequest = buildRequest({
|
|
308
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(PublicDashboardRateLimit.resolveClientIp(request)).toBe(
|
|
312
|
+
"203.0.113.7",
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("handles a header delivered as an array", () => {
|
|
317
|
+
const request: ExpressRequest = buildRequest({
|
|
318
|
+
headers: { "x-forwarded-for": ["9.9.9.9", "203.0.113.7"] },
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(PublicDashboardRateLimit.resolveClientIp(request)).toBe(
|
|
322
|
+
"203.0.113.7",
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
/*
|
|
327
|
+
* Padding around a real entry is fine — proxies emit it.
|
|
328
|
+
*/
|
|
329
|
+
it("tolerates whitespace around the trusted entry", () => {
|
|
330
|
+
const request: ExpressRequest = buildRequest({
|
|
331
|
+
headers: { "x-forwarded-for": "9.9.9.9, 203.0.113.7 " },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(PublicDashboardRateLimit.resolveClientIp(request)).toBe(
|
|
335
|
+
"203.0.113.7",
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
/*
|
|
340
|
+
* But an EMPTY entry in the trusted position is refused rather than
|
|
341
|
+
* skipped, and that distinction is load-bearing.
|
|
342
|
+
*
|
|
343
|
+
* Skipping empties would renumber the list, and every position to the left
|
|
344
|
+
* of the trusted one is caller-supplied. A caller who appends a trailing
|
|
345
|
+
* comma to their own spoofed value — "1.2.3.4,," — would see the skip walk
|
|
346
|
+
* back onto their own entry and be billed as whatever address they asked
|
|
347
|
+
* for. Failing closed puts them in the shared "unknown" bucket instead,
|
|
348
|
+
* which is more restrictive for them, never less.
|
|
349
|
+
*/
|
|
350
|
+
it("refuses an empty trusted position rather than walking left onto caller data", () => {
|
|
351
|
+
const request: ExpressRequest = buildRequest({
|
|
352
|
+
headers: { "x-forwarded-for": "9.9.9.9, , 203.0.113.7 , " },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(PublicDashboardRateLimit.resolveClientIp(request)).toBe("unknown");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("does not let a trailing comma promote the caller's own entry", () => {
|
|
359
|
+
const spoofed: string = PublicDashboardRateLimit.resolveClientIp(
|
|
360
|
+
buildRequest({ headers: { "x-forwarded-for": "1.2.3.4,," } }),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
/* Never the value the caller asked to be billed as. */
|
|
364
|
+
expect(spoofed).not.toBe("1.2.3.4");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("falls back to the socket address with no forwarding header", () => {
|
|
368
|
+
const request: ExpressRequest = buildRequest({
|
|
369
|
+
socketAddress: "198.51.100.4",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(PublicDashboardRateLimit.resolveClientIp(request)).toBe(
|
|
373
|
+
"198.51.100.4",
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("falls back to req.ip when there is no socket address", () => {
|
|
378
|
+
const request: ExpressRequest = buildRequest({ ip: "198.51.100.9" });
|
|
379
|
+
|
|
380
|
+
expect(PublicDashboardRateLimit.resolveClientIp(request)).toBe(
|
|
381
|
+
"198.51.100.9",
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("puts wholly unidentifiable callers in one shared bucket", () => {
|
|
386
|
+
expect(PublicDashboardRateLimit.resolveClientIp(buildRequest())).toBe(
|
|
387
|
+
"unknown",
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("preserves IPv6 addresses", () => {
|
|
392
|
+
const request: ExpressRequest = buildRequest({
|
|
393
|
+
headers: { "x-forwarded-for": "2001:db8::8a2e:370:7334" },
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(PublicDashboardRateLimit.resolveClientIp(request)).toBe(
|
|
397
|
+
"2001:db8::8a2e:370:7334",
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("truncates an over-long address so it cannot bloat a Redis key", () => {
|
|
402
|
+
const request: ExpressRequest = buildRequest({
|
|
403
|
+
headers: { "x-forwarded-for": "a".repeat(5000) },
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(
|
|
407
|
+
PublicDashboardRateLimit.resolveClientIp(request).length,
|
|
408
|
+
).toBeLessThanOrEqual(64);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("replaces characters that would make a messy Redis key", () => {
|
|
412
|
+
const request: ExpressRequest = buildRequest({
|
|
413
|
+
headers: { "x-forwarded-for": "1.2.3.4\n\r evil*key" },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const resolved: string =
|
|
417
|
+
PublicDashboardRateLimit.resolveClientIp(request);
|
|
418
|
+
|
|
419
|
+
expect(resolved).not.toContain("\n");
|
|
420
|
+
expect(resolved).not.toContain("*");
|
|
421
|
+
expect(resolved).toMatch(/^[a-zA-Z0-9._:%\-[\]_]+$/);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("resolveDashboardKey", () => {
|
|
426
|
+
it("keys on :dashboardId", () => {
|
|
427
|
+
const id: ObjectID = ObjectID.generate();
|
|
428
|
+
|
|
429
|
+
expect(
|
|
430
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
431
|
+
buildRequest({ params: { dashboardId: id.toString() } }),
|
|
432
|
+
),
|
|
433
|
+
).toBe(`id:${id.toString().toLowerCase()}`);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("keys on :dashboardIdOrDomain, as /overview and /seo use", () => {
|
|
437
|
+
const id: ObjectID = ObjectID.generate();
|
|
438
|
+
|
|
439
|
+
expect(
|
|
440
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
441
|
+
buildRequest({ params: { dashboardIdOrDomain: id.toString() } }),
|
|
442
|
+
),
|
|
443
|
+
).toBe(`id:${id.toString().toLowerCase()}`);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("keys on a custom domain in the path", () => {
|
|
447
|
+
expect(
|
|
448
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
449
|
+
buildRequest({
|
|
450
|
+
params: { dashboardIdOrDomain: "status.example.com" },
|
|
451
|
+
}),
|
|
452
|
+
),
|
|
453
|
+
).toBe("dom:status.example.com");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("keys on the body domain, as /domain uses", () => {
|
|
457
|
+
expect(
|
|
458
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
459
|
+
buildRequest({ body: { domain: "dash.example.com" } }),
|
|
460
|
+
),
|
|
461
|
+
).toBe("dom:dash.example.com");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
/*
|
|
465
|
+
* Casing must not split a bucket, or the limit is bypassed by varying
|
|
466
|
+
* case alone.
|
|
467
|
+
*/
|
|
468
|
+
it("gives a differently-cased id the same bucket", () => {
|
|
469
|
+
const id: string = ObjectID.generate().toString();
|
|
470
|
+
|
|
471
|
+
expect(
|
|
472
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
473
|
+
buildRequest({ params: { dashboardId: id.toUpperCase() } }),
|
|
474
|
+
),
|
|
475
|
+
).toBe(
|
|
476
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
477
|
+
buildRequest({ params: { dashboardId: id.toLowerCase() } }),
|
|
478
|
+
),
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("gives a differently-cased domain the same bucket", () => {
|
|
483
|
+
expect(
|
|
484
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
485
|
+
buildRequest({
|
|
486
|
+
params: { dashboardIdOrDomain: "DASH.Example.COM" },
|
|
487
|
+
}),
|
|
488
|
+
),
|
|
489
|
+
).toBe("dom:dash.example.com");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("ignores surrounding whitespace", () => {
|
|
493
|
+
const id: string = ObjectID.generate().toString();
|
|
494
|
+
|
|
495
|
+
expect(
|
|
496
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
497
|
+
buildRequest({ params: { dashboardId: ` ${id} ` } }),
|
|
498
|
+
),
|
|
499
|
+
).toBe(`id:${id.toLowerCase()}`);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
/*
|
|
503
|
+
* Junk path segments all share one bucket. That bounds Redis memory
|
|
504
|
+
* against a caller feeding random garbage, and it is the right grouping:
|
|
505
|
+
* none of those requests can name a real dashboard.
|
|
506
|
+
*/
|
|
507
|
+
it("collapses junk to a single shared bucket", () => {
|
|
508
|
+
const first: string = PublicDashboardRateLimit.resolveDashboardKey(
|
|
509
|
+
buildRequest({ params: { dashboardId: "not-an-id-at-all" } }),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
const second: string = PublicDashboardRateLimit.resolveDashboardKey(
|
|
513
|
+
buildRequest({ params: { dashboardId: "%%%%%%" } }),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
expect(first).toBe("invalid");
|
|
517
|
+
expect(second).toBe("invalid");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("collapses an over-long segment rather than building a huge key", () => {
|
|
521
|
+
expect(
|
|
522
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
523
|
+
buildRequest({ params: { dashboardId: "a".repeat(10000) } }),
|
|
524
|
+
),
|
|
525
|
+
).toBe("invalid");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("rejects a domain longer than the DNS maximum", () => {
|
|
529
|
+
const tooLong: string = `${"a".repeat(60)}.${"b".repeat(60)}.${"c".repeat(
|
|
530
|
+
60,
|
|
531
|
+
)}.${"d".repeat(60)}.example.com`;
|
|
532
|
+
|
|
533
|
+
expect(tooLong.length).toBeGreaterThan(253);
|
|
534
|
+
expect(
|
|
535
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
536
|
+
buildRequest({ params: { dashboardIdOrDomain: tooLong } }),
|
|
537
|
+
),
|
|
538
|
+
).toBe("invalid");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("uses a stable key when no dashboard is named at all", () => {
|
|
542
|
+
expect(PublicDashboardRateLimit.resolveDashboardKey(buildRequest())).toBe(
|
|
543
|
+
"none",
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("ignores a blank parameter", () => {
|
|
548
|
+
expect(
|
|
549
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
550
|
+
buildRequest({ params: { dashboardId: " " } }),
|
|
551
|
+
),
|
|
552
|
+
).toBe("none");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("does not throw when there is no body", () => {
|
|
556
|
+
expect(() => {
|
|
557
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
558
|
+
buildRequest({ params: {} }),
|
|
559
|
+
);
|
|
560
|
+
}).not.toThrow();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("ignores a non-string body domain", () => {
|
|
564
|
+
expect(
|
|
565
|
+
PublicDashboardRateLimit.resolveDashboardKey(
|
|
566
|
+
buildRequest({ body: { domain: { evil: true } } }),
|
|
567
|
+
),
|
|
568
|
+
).toBe("none");
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe("consume - per dashboard budget", () => {
|
|
573
|
+
it("allows requests up to the limit", async () => {
|
|
574
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT; i++) {
|
|
575
|
+
const decision: PublicDashboardRateLimitDecision = await consumeRead(
|
|
576
|
+
{},
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
expect(decision.outcome).toBe(PublicDashboardRateLimitOutcome.Allowed);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("rejects the request after the limit with a dashboard scope", async () => {
|
|
584
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT; i++) {
|
|
585
|
+
await consumeRead({});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const decision: PublicDashboardRateLimitDecision = await consumeRead({});
|
|
589
|
+
|
|
590
|
+
expect(decision.outcome).toBe(
|
|
591
|
+
PublicDashboardRateLimitOutcome.RateLimited,
|
|
592
|
+
);
|
|
593
|
+
expect(decision.scope).toBe(PublicDashboardRateLimitScope.Dashboard);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("keeps separate budgets for separate client addresses", async () => {
|
|
597
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 5; i++) {
|
|
598
|
+
await consumeRead({ clientIp: "203.0.113.7" });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const other: PublicDashboardRateLimitDecision = await consumeRead({
|
|
602
|
+
clientIp: "198.51.100.4",
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
expect(other.outcome).toBe(PublicDashboardRateLimitOutcome.Allowed);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("keeps separate budgets for separate dashboards on one address", async () => {
|
|
609
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 5; i++) {
|
|
610
|
+
await consumeRead({ dashboardKey: "id:dashboard-a" });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const other: PublicDashboardRateLimitDecision = await consumeRead({
|
|
614
|
+
dashboardKey: "id:dashboard-b",
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
expect(other.outcome).toBe(PublicDashboardRateLimitOutcome.Allowed);
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
describe("consume - per address ceiling", () => {
|
|
622
|
+
/*
|
|
623
|
+
* The bypass this counter exists to close. Keyed only on dashboard id +
|
|
624
|
+
* address, an attacker rotates the id on every request and never fills a
|
|
625
|
+
* bucket — while every request still costs a Postgres lookup, 404 or not.
|
|
626
|
+
*/
|
|
627
|
+
it("stops an attacker who rotates the dashboard id on every request", async () => {
|
|
628
|
+
let rejected: PublicDashboardRateLimitDecision | null = null;
|
|
629
|
+
|
|
630
|
+
for (let i: number = 0; i < READ_PER_IP_LIMIT + 10; i++) {
|
|
631
|
+
const decision: PublicDashboardRateLimitDecision = await consumeRead({
|
|
632
|
+
dashboardKey: `id:rotating-${i}`,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
if (
|
|
636
|
+
decision.outcome === PublicDashboardRateLimitOutcome.RateLimited &&
|
|
637
|
+
!rejected
|
|
638
|
+
) {
|
|
639
|
+
rejected = decision;
|
|
640
|
+
expect(i).toBe(READ_PER_IP_LIMIT);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
expect(rejected).not.toBeNull();
|
|
645
|
+
expect(rejected?.scope).toBe(PublicDashboardRateLimitScope.Ip);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("does not let one address's ceiling affect another", async () => {
|
|
649
|
+
for (let i: number = 0; i < READ_PER_IP_LIMIT + 10; i++) {
|
|
650
|
+
await consumeRead({
|
|
651
|
+
dashboardKey: `id:rotating-${i}`,
|
|
652
|
+
clientIp: "203.0.113.7",
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const other: PublicDashboardRateLimitDecision = await consumeRead({
|
|
657
|
+
dashboardKey: "id:dashboard-a",
|
|
658
|
+
clientIp: "198.51.100.4",
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
expect(other.outcome).toBe(PublicDashboardRateLimitOutcome.Allowed);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("reports the dashboard scope first when both counters are over", async () => {
|
|
665
|
+
for (let i: number = 0; i < READ_PER_IP_LIMIT + 10; i++) {
|
|
666
|
+
await consumeRead({});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const decision: PublicDashboardRateLimitDecision = await consumeRead({});
|
|
670
|
+
|
|
671
|
+
expect(decision.scope).toBe(PublicDashboardRateLimitScope.Dashboard);
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
describe("consume - window behaviour", () => {
|
|
676
|
+
it("sets a TTL only on the request that created the counter", async () => {
|
|
677
|
+
await consumeRead({});
|
|
678
|
+
|
|
679
|
+
const dashboardKey: string | undefined = Array.from(
|
|
680
|
+
client.counters.keys(),
|
|
681
|
+
).find((key: string) => {
|
|
682
|
+
return key.includes(":d:");
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
expect(dashboardKey).toBeDefined();
|
|
686
|
+
expect(client.expiresForKey(dashboardKey as string)).toHaveLength(1);
|
|
687
|
+
|
|
688
|
+
for (let i: number = 0; i < 20; i++) {
|
|
689
|
+
await consumeRead({});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/*
|
|
693
|
+
* Re-issuing EXPIRE on every increment would slide the window forward
|
|
694
|
+
* for as long as the load continued, so a client that tripped the limit
|
|
695
|
+
* could never recover. The TTL must be written exactly once.
|
|
696
|
+
*/
|
|
697
|
+
expect(client.expiresForKey(dashboardKey as string)).toHaveLength(1);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("gives the counter a TTL longer than its own window", async () => {
|
|
701
|
+
await consumeRead({});
|
|
702
|
+
|
|
703
|
+
expect(client.expires.length).toBeGreaterThan(0);
|
|
704
|
+
|
|
705
|
+
for (const recorded of client.expires) {
|
|
706
|
+
expect(recorded.ttlSeconds).toBeGreaterThan(READ_WINDOW_SECONDS);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("issues no further EXPIRE once both counters exist", async () => {
|
|
711
|
+
await consumeRead({});
|
|
712
|
+
const afterFirst: number = client.expires.length;
|
|
713
|
+
|
|
714
|
+
await consumeRead({});
|
|
715
|
+
|
|
716
|
+
expect(client.expires.length).toBe(afterFirst);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("starts a fresh allowance when the window rolls", async () => {
|
|
720
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 1; i++) {
|
|
721
|
+
await consumeRead({});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
725
|
+
PublicDashboardRateLimitOutcome.RateLimited,
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
currentTime = currentTime + READ_WINDOW_SECONDS * 1000;
|
|
729
|
+
|
|
730
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
731
|
+
PublicDashboardRateLimitOutcome.Allowed,
|
|
732
|
+
);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("does not roll early within the same window", async () => {
|
|
736
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 1; i++) {
|
|
737
|
+
await consumeRead({});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
currentTime = currentTime + (READ_WINDOW_SECONDS - 1) * 1000;
|
|
741
|
+
|
|
742
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
743
|
+
PublicDashboardRateLimitOutcome.RateLimited,
|
|
744
|
+
);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
/*
|
|
748
|
+
* A rejected request still increments. Otherwise a client that keeps
|
|
749
|
+
* hammering is handed a fresh allowance the moment it is refused.
|
|
750
|
+
*/
|
|
751
|
+
it("keeps the window pinned by counting rejected requests too", async () => {
|
|
752
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT; i++) {
|
|
753
|
+
await consumeRead({});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const countBefore: number = client.incrCallCount;
|
|
757
|
+
|
|
758
|
+
await consumeRead({});
|
|
759
|
+
await consumeRead({});
|
|
760
|
+
|
|
761
|
+
expect(client.incrCallCount).toBe(countBefore + 4);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("reports a Retry-After inside the current window", async () => {
|
|
765
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 1; i++) {
|
|
766
|
+
await consumeRead({});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const decision: PublicDashboardRateLimitDecision = await consumeRead({});
|
|
770
|
+
|
|
771
|
+
expect(decision.retryAfterSeconds).toBeGreaterThan(0);
|
|
772
|
+
expect(decision.retryAfterSeconds).toBeLessThanOrEqual(
|
|
773
|
+
READ_WINDOW_SECONDS,
|
|
774
|
+
);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("shrinks Retry-After as the window drains", async () => {
|
|
778
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 1; i++) {
|
|
779
|
+
await consumeRead({});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const atStart: PublicDashboardRateLimitDecision = await consumeRead({});
|
|
783
|
+
|
|
784
|
+
currentTime = currentTime + 30_000;
|
|
785
|
+
|
|
786
|
+
const midway: PublicDashboardRateLimitDecision = await consumeRead({});
|
|
787
|
+
|
|
788
|
+
expect(midway.retryAfterSeconds).toBeLessThan(
|
|
789
|
+
atStart.retryAfterSeconds as number,
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("never reports a Retry-After of zero at the very end of a window", async () => {
|
|
794
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 1; i++) {
|
|
795
|
+
await consumeRead({});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
currentTime = currentTime + READ_WINDOW_SECONDS * 1000 - 1;
|
|
799
|
+
|
|
800
|
+
const decision: PublicDashboardRateLimitDecision = await consumeRead({});
|
|
801
|
+
|
|
802
|
+
/*
|
|
803
|
+
* Window rolled, so this one is allowed — but the maths must not
|
|
804
|
+
* produce a zero for any caller that is refused.
|
|
805
|
+
*/
|
|
806
|
+
expect(decision.retryAfterSeconds ?? 1).toBeGreaterThan(0);
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
describe("consume - master password bucket", () => {
|
|
811
|
+
const consumePassword: () => Promise<PublicDashboardRateLimitDecision> =
|
|
812
|
+
() => {
|
|
813
|
+
return PublicDashboardRateLimit.consume({
|
|
814
|
+
dashboardKey: "id:dashboard-a",
|
|
815
|
+
clientIp: "203.0.113.7",
|
|
816
|
+
bucket: PublicDashboardRateLimitBucket.MasterPassword,
|
|
817
|
+
});
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
it("allows a human's worth of attempts", async () => {
|
|
821
|
+
for (let i: number = 0; i < MASTER_PASSWORD_PER_DASHBOARD_LIMIT; i++) {
|
|
822
|
+
expect((await consumePassword()).outcome).toBe(
|
|
823
|
+
PublicDashboardRateLimitOutcome.Allowed,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("shuts the guessing oracle after that", async () => {
|
|
829
|
+
for (let i: number = 0; i < MASTER_PASSWORD_PER_DASHBOARD_LIMIT; i++) {
|
|
830
|
+
await consumePassword();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const decision: PublicDashboardRateLimitDecision =
|
|
834
|
+
await consumePassword();
|
|
835
|
+
|
|
836
|
+
expect(decision.outcome).toBe(
|
|
837
|
+
PublicDashboardRateLimitOutcome.RateLimited,
|
|
838
|
+
);
|
|
839
|
+
expect(decision.scope).toBe(PublicDashboardRateLimitScope.Dashboard);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("is far tighter than the read budget", () => {
|
|
843
|
+
expect(MASTER_PASSWORD_PER_DASHBOARD_LIMIT).toBeLessThan(
|
|
844
|
+
READ_PER_DASHBOARD_LIMIT,
|
|
845
|
+
);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("caps an attacker rotating dashboards to guess against many at once", async () => {
|
|
849
|
+
let rejected: PublicDashboardRateLimitDecision | null = null;
|
|
850
|
+
|
|
851
|
+
for (let i: number = 0; i < MASTER_PASSWORD_PER_IP_LIMIT + 5; i++) {
|
|
852
|
+
const decision: PublicDashboardRateLimitDecision =
|
|
853
|
+
await PublicDashboardRateLimit.consume({
|
|
854
|
+
dashboardKey: `id:dashboard-${i}`,
|
|
855
|
+
clientIp: "203.0.113.7",
|
|
856
|
+
bucket: PublicDashboardRateLimitBucket.MasterPassword,
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
if (
|
|
860
|
+
decision.outcome === PublicDashboardRateLimitOutcome.RateLimited &&
|
|
861
|
+
!rejected
|
|
862
|
+
) {
|
|
863
|
+
rejected = decision;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
expect(rejected?.scope).toBe(PublicDashboardRateLimitScope.Ip);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("holds its window far longer than the read window", async () => {
|
|
871
|
+
for (
|
|
872
|
+
let i: number = 0;
|
|
873
|
+
i < MASTER_PASSWORD_PER_DASHBOARD_LIMIT + 1;
|
|
874
|
+
i++
|
|
875
|
+
) {
|
|
876
|
+
await consumePassword();
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/* A read window later, password attempts are still refused. */
|
|
880
|
+
currentTime = currentTime + READ_WINDOW_SECONDS * 1000;
|
|
881
|
+
|
|
882
|
+
expect((await consumePassword()).outcome).toBe(
|
|
883
|
+
PublicDashboardRateLimitOutcome.RateLimited,
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
currentTime = currentTime + MASTER_PASSWORD_WINDOW_SECONDS * 1000;
|
|
887
|
+
|
|
888
|
+
expect((await consumePassword()).outcome).toBe(
|
|
889
|
+
PublicDashboardRateLimitOutcome.Allowed,
|
|
890
|
+
);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
/*
|
|
894
|
+
* Reading a dashboard and guessing its password must not draw on the same
|
|
895
|
+
* counter, in either direction: a busy dashboard would otherwise lock out
|
|
896
|
+
* password entry, and password attempts would otherwise be laundered
|
|
897
|
+
* through the far larger read budget.
|
|
898
|
+
*/
|
|
899
|
+
it("does not share a counter with the read bucket", async () => {
|
|
900
|
+
for (
|
|
901
|
+
let i: number = 0;
|
|
902
|
+
i < MASTER_PASSWORD_PER_DASHBOARD_LIMIT + 5;
|
|
903
|
+
i++
|
|
904
|
+
) {
|
|
905
|
+
await PublicDashboardRateLimit.consume({
|
|
906
|
+
dashboardKey: "id:dashboard-a",
|
|
907
|
+
clientIp: "203.0.113.7",
|
|
908
|
+
bucket: PublicDashboardRateLimitBucket.MasterPassword,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
913
|
+
PublicDashboardRateLimitOutcome.Allowed,
|
|
914
|
+
);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("is not relaxed by heavy read traffic", async () => {
|
|
918
|
+
for (let i: number = 0; i < 100; i++) {
|
|
919
|
+
await consumeRead({});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
for (let i: number = 0; i < MASTER_PASSWORD_PER_DASHBOARD_LIMIT; i++) {
|
|
923
|
+
await consumePassword();
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
expect((await consumePassword()).outcome).toBe(
|
|
927
|
+
PublicDashboardRateLimitOutcome.RateLimited,
|
|
928
|
+
);
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
describe("consume - counter unavailable", () => {
|
|
933
|
+
it("reports unavailable when Redis has no client", async () => {
|
|
934
|
+
getClientMock.mockReturnValue(null);
|
|
935
|
+
|
|
936
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
937
|
+
PublicDashboardRateLimitOutcome.CounterUnavailable,
|
|
938
|
+
);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it("reports unavailable when Redis is not connected", async () => {
|
|
942
|
+
isConnectedMock.mockReturnValue(false);
|
|
943
|
+
|
|
944
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
945
|
+
PublicDashboardRateLimitOutcome.CounterUnavailable,
|
|
946
|
+
);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it("reports unavailable when the pipeline throws", async () => {
|
|
950
|
+
client.failNextExec = new Error("connection reset");
|
|
951
|
+
|
|
952
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
953
|
+
PublicDashboardRateLimitOutcome.CounterUnavailable,
|
|
954
|
+
);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it("reports unavailable when the pipeline returns null", async () => {
|
|
958
|
+
client.malformedExecResult = null;
|
|
959
|
+
|
|
960
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
961
|
+
PublicDashboardRateLimitOutcome.CounterUnavailable,
|
|
962
|
+
);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("reports unavailable when the pipeline returns too few results", async () => {
|
|
966
|
+
client.malformedExecResult = [[null, 1]];
|
|
967
|
+
|
|
968
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
969
|
+
PublicDashboardRateLimitOutcome.CounterUnavailable,
|
|
970
|
+
);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it("reports unavailable when a command inside the pipeline failed", async () => {
|
|
974
|
+
client.malformedExecResult = [
|
|
975
|
+
[new Error("READONLY"), null],
|
|
976
|
+
[null, 1],
|
|
977
|
+
];
|
|
978
|
+
|
|
979
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
980
|
+
PublicDashboardRateLimitOutcome.CounterUnavailable,
|
|
981
|
+
);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it("reports unavailable when a counter comes back non-numeric", async () => {
|
|
985
|
+
client.malformedExecResult = [
|
|
986
|
+
[null, "1"],
|
|
987
|
+
[null, 1],
|
|
988
|
+
];
|
|
989
|
+
|
|
990
|
+
expect((await consumeRead({})).outcome).toBe(
|
|
991
|
+
PublicDashboardRateLimitOutcome.CounterUnavailable,
|
|
992
|
+
);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it("does not throw out of consume on any failure", async () => {
|
|
996
|
+
client.failNextExec = new Error("boom");
|
|
997
|
+
|
|
998
|
+
await expect(consumeRead({})).resolves.toBeDefined();
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
describe("getMiddleware", () => {
|
|
1003
|
+
const runMiddleware: (data: {
|
|
1004
|
+
bucket: PublicDashboardRateLimitBucket;
|
|
1005
|
+
request?: ExpressRequest;
|
|
1006
|
+
}) => Promise<{
|
|
1007
|
+
nextCalled: boolean;
|
|
1008
|
+
headers: Record<string, string>;
|
|
1009
|
+
}> = async (data: {
|
|
1010
|
+
bucket: PublicDashboardRateLimitBucket;
|
|
1011
|
+
request?: ExpressRequest;
|
|
1012
|
+
}) => {
|
|
1013
|
+
const { response, headers } = buildResponse();
|
|
1014
|
+
let nextCalled: boolean = false;
|
|
1015
|
+
|
|
1016
|
+
const next: NextFunction = (() => {
|
|
1017
|
+
nextCalled = true;
|
|
1018
|
+
}) as unknown as NextFunction;
|
|
1019
|
+
|
|
1020
|
+
await PublicDashboardRateLimit.getMiddleware(data.bucket)(
|
|
1021
|
+
data.request ||
|
|
1022
|
+
buildRequest({
|
|
1023
|
+
params: { dashboardId: ObjectID.generate().toString() },
|
|
1024
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1025
|
+
}),
|
|
1026
|
+
response,
|
|
1027
|
+
next,
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
return { nextCalled, headers };
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
it("passes an allowed request through", async () => {
|
|
1034
|
+
const result: { nextCalled: boolean } = await runMiddleware({
|
|
1035
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
expect(result.nextCalled).toBe(true);
|
|
1039
|
+
expect(sendErrorResponseMock).not.toHaveBeenCalled();
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("defaults to the read bucket", async () => {
|
|
1043
|
+
const { response } = buildResponse();
|
|
1044
|
+
let nextCalled: boolean = false;
|
|
1045
|
+
|
|
1046
|
+
await PublicDashboardRateLimit.getMiddleware()(
|
|
1047
|
+
buildRequest({ headers: { "x-forwarded-for": "203.0.113.7" } }),
|
|
1048
|
+
response,
|
|
1049
|
+
(() => {
|
|
1050
|
+
nextCalled = true;
|
|
1051
|
+
}) as unknown as NextFunction,
|
|
1052
|
+
);
|
|
1053
|
+
|
|
1054
|
+
expect(nextCalled).toBe(true);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
/*
|
|
1058
|
+
* The property the whole change rests on: a refused request must not
|
|
1059
|
+
* reach the handler behind it, or the ClickHouse/Postgres work still
|
|
1060
|
+
* happens and the limiter is decoration.
|
|
1061
|
+
*/
|
|
1062
|
+
it("stops a refused request from reaching the route handler", async () => {
|
|
1063
|
+
const request: ExpressRequest = buildRequest({
|
|
1064
|
+
params: { dashboardId: ObjectID.generate().toString() },
|
|
1065
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT; i++) {
|
|
1069
|
+
await runMiddleware({
|
|
1070
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1071
|
+
request,
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
sendErrorResponseMock.mockClear();
|
|
1076
|
+
|
|
1077
|
+
const result: { nextCalled: boolean } = await runMiddleware({
|
|
1078
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1079
|
+
request,
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
expect(result.nextCalled).toBe(false);
|
|
1083
|
+
expect(sendErrorResponseMock).toHaveBeenCalledTimes(1);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it("answers a refusal with 429", async () => {
|
|
1087
|
+
const request: ExpressRequest = buildRequest({
|
|
1088
|
+
params: { dashboardId: ObjectID.generate().toString() },
|
|
1089
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 1; i++) {
|
|
1093
|
+
await runMiddleware({
|
|
1094
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1095
|
+
request,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const error: Exception = sendErrorResponseMock.mock
|
|
1100
|
+
.calls[0]?.[2] as Exception;
|
|
1101
|
+
|
|
1102
|
+
expect(error.code).toBe(ExceptionCode.TooManyRequestsException);
|
|
1103
|
+
expect(error.code).toBe(429);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it("sets Retry-After on a refusal", async () => {
|
|
1107
|
+
const request: ExpressRequest = buildRequest({
|
|
1108
|
+
params: { dashboardId: ObjectID.generate().toString() },
|
|
1109
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
let headers: Record<string, string> = {};
|
|
1113
|
+
|
|
1114
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 1; i++) {
|
|
1115
|
+
headers = (
|
|
1116
|
+
await runMiddleware({
|
|
1117
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1118
|
+
request,
|
|
1119
|
+
})
|
|
1120
|
+
).headers;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
expect(Number(headers["Retry-After"])).toBeGreaterThan(0);
|
|
1124
|
+
expect(Number(headers["Retry-After"])).toBeLessThanOrEqual(
|
|
1125
|
+
READ_WINDOW_SECONDS,
|
|
1126
|
+
);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it("does not leak the limit or the counter value in the message", async () => {
|
|
1130
|
+
const request: ExpressRequest = buildRequest({
|
|
1131
|
+
params: { dashboardId: ObjectID.generate().toString() },
|
|
1132
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 1; i++) {
|
|
1136
|
+
await runMiddleware({
|
|
1137
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1138
|
+
request,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const error: Exception = sendErrorResponseMock.mock
|
|
1143
|
+
.calls[0]?.[2] as Exception;
|
|
1144
|
+
|
|
1145
|
+
expect(error.message).not.toMatch(/\d/);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it("tells a refused password attempt that it was a password attempt", async () => {
|
|
1149
|
+
const request: ExpressRequest = buildRequest({
|
|
1150
|
+
params: { dashboardId: ObjectID.generate().toString() },
|
|
1151
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
for (
|
|
1155
|
+
let i: number = 0;
|
|
1156
|
+
i < MASTER_PASSWORD_PER_DASHBOARD_LIMIT + 1;
|
|
1157
|
+
i++
|
|
1158
|
+
) {
|
|
1159
|
+
await runMiddleware({
|
|
1160
|
+
bucket: PublicDashboardRateLimitBucket.MasterPassword,
|
|
1161
|
+
request,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const error: Exception = sendErrorResponseMock.mock
|
|
1166
|
+
.calls[0]?.[2] as Exception;
|
|
1167
|
+
|
|
1168
|
+
expect(error.code).toBe(ExceptionCode.TooManyRequestsException);
|
|
1169
|
+
expect(error.message.toLowerCase()).toContain("password");
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it("survives a response object with no setHeader", async () => {
|
|
1173
|
+
const request: ExpressRequest = buildRequest({
|
|
1174
|
+
params: { dashboardId: ObjectID.generate().toString() },
|
|
1175
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
const middleware: (
|
|
1179
|
+
req: ExpressRequest,
|
|
1180
|
+
res: ExpressResponse,
|
|
1181
|
+
next: NextFunction,
|
|
1182
|
+
) => Promise<void> = PublicDashboardRateLimit.getMiddleware(
|
|
1183
|
+
PublicDashboardRateLimitBucket.Read,
|
|
1184
|
+
);
|
|
1185
|
+
|
|
1186
|
+
const bareResponse: ExpressResponse = {} as unknown as ExpressResponse;
|
|
1187
|
+
|
|
1188
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 2; i++) {
|
|
1189
|
+
await expect(
|
|
1190
|
+
middleware(request, bareResponse, (() => {}) as NextFunction),
|
|
1191
|
+
).resolves.not.toThrow();
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
/*
|
|
1196
|
+
* Reads fail OPEN when Redis is gone: these are unauthenticated read-only
|
|
1197
|
+
* endpoints, and blacking out every customer's public dashboard over a
|
|
1198
|
+
* Redis blip is worse than an unbounded window for its duration.
|
|
1199
|
+
*/
|
|
1200
|
+
it("serves reads unthrottled rather than failing when Redis is gone", async () => {
|
|
1201
|
+
isConnectedMock.mockReturnValue(false);
|
|
1202
|
+
|
|
1203
|
+
const result: { nextCalled: boolean } = await runMiddleware({
|
|
1204
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
expect(result.nextCalled).toBe(true);
|
|
1208
|
+
expect(sendErrorResponseMock).not.toHaveBeenCalled();
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
/*
|
|
1212
|
+
* /master-password fails CLOSED for the same outage. There the counter is
|
|
1213
|
+
* not a load control, it is the only bound on password guessing — serving
|
|
1214
|
+
* without it is serving an unlimited oracle.
|
|
1215
|
+
*/
|
|
1216
|
+
it("refuses password attempts when Redis is gone", async () => {
|
|
1217
|
+
isConnectedMock.mockReturnValue(false);
|
|
1218
|
+
|
|
1219
|
+
const result: { nextCalled: boolean } = await runMiddleware({
|
|
1220
|
+
bucket: PublicDashboardRateLimitBucket.MasterPassword,
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
expect(result.nextCalled).toBe(false);
|
|
1224
|
+
expect(sendErrorResponseMock).toHaveBeenCalledTimes(1);
|
|
1225
|
+
|
|
1226
|
+
const error: Exception = sendErrorResponseMock.mock
|
|
1227
|
+
.calls[0]?.[2] as Exception;
|
|
1228
|
+
|
|
1229
|
+
expect(error.code).toBe(ExceptionCode.ServiceUnavailableException);
|
|
1230
|
+
expect(error.code).toBe(503);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it("refuses password attempts when the counter errors", async () => {
|
|
1234
|
+
client.failNextExec = new Error("connection reset");
|
|
1235
|
+
|
|
1236
|
+
const result: { nextCalled: boolean } = await runMiddleware({
|
|
1237
|
+
bucket: PublicDashboardRateLimitBucket.MasterPassword,
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
expect(result.nextCalled).toBe(false);
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it("still serves reads when the counter errors", async () => {
|
|
1244
|
+
client.failNextExec = new Error("connection reset");
|
|
1245
|
+
|
|
1246
|
+
const result: { nextCalled: boolean } = await runMiddleware({
|
|
1247
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
expect(result.nextCalled).toBe(true);
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
/*
|
|
1254
|
+
* A limiter exists because the caller keeps knocking. Logging every
|
|
1255
|
+
* refusal turns their flood into a second flood in the log pipeline —
|
|
1256
|
+
* the very tool an operator needs to see the first one.
|
|
1257
|
+
*/
|
|
1258
|
+
it("logs the moment a caller crosses the line, then stays quiet", async () => {
|
|
1259
|
+
const request: ExpressRequest = buildRequest({
|
|
1260
|
+
params: { dashboardId: ObjectID.generate().toString() },
|
|
1261
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT; i++) {
|
|
1265
|
+
await runMiddleware({
|
|
1266
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1267
|
+
request,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
loggerWarnMock.mockClear();
|
|
1272
|
+
|
|
1273
|
+
/* The crossing request. */
|
|
1274
|
+
await runMiddleware({
|
|
1275
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1276
|
+
request,
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
expect(loggerWarnMock).toHaveBeenCalledTimes(1);
|
|
1280
|
+
|
|
1281
|
+
/* Everything after it is refused just as hard, but silently. */
|
|
1282
|
+
for (let i: number = 0; i < 200; i++) {
|
|
1283
|
+
await runMiddleware({
|
|
1284
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1285
|
+
request,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
expect(loggerWarnMock).toHaveBeenCalledTimes(1);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it("logs the crossing again once the window rolls", async () => {
|
|
1293
|
+
const request: ExpressRequest = buildRequest({
|
|
1294
|
+
params: { dashboardId: ObjectID.generate().toString() },
|
|
1295
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 5; i++) {
|
|
1299
|
+
await runMiddleware({
|
|
1300
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1301
|
+
request,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
currentTime = currentTime + READ_WINDOW_SECONDS * 1000;
|
|
1306
|
+
loggerWarnMock.mockClear();
|
|
1307
|
+
|
|
1308
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT + 5; i++) {
|
|
1309
|
+
await runMiddleware({
|
|
1310
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1311
|
+
request,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
expect(loggerWarnMock).toHaveBeenCalledTimes(1);
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
/*
|
|
1319
|
+
* A Redis outage puts EVERY request on the unavailable path, so an
|
|
1320
|
+
* unguarded log there is one line per request for the whole outage.
|
|
1321
|
+
*/
|
|
1322
|
+
it("does not log once per request while Redis is down", async () => {
|
|
1323
|
+
isConnectedMock.mockReturnValue(false);
|
|
1324
|
+
loggerWarnMock.mockClear();
|
|
1325
|
+
|
|
1326
|
+
for (let i: number = 0; i < 100; i++) {
|
|
1327
|
+
await runMiddleware({ bucket: PublicDashboardRateLimitBucket.Read });
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/* One message plus its attributes line, not 200. */
|
|
1331
|
+
expect(loggerWarnMock.mock.calls.length).toBeLessThanOrEqual(2);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it("logs the outage again after the throttle interval", async () => {
|
|
1335
|
+
isConnectedMock.mockReturnValue(false);
|
|
1336
|
+
|
|
1337
|
+
await runMiddleware({ bucket: PublicDashboardRateLimitBucket.Read });
|
|
1338
|
+
|
|
1339
|
+
currentTime = currentTime + 60_000;
|
|
1340
|
+
loggerWarnMock.mockClear();
|
|
1341
|
+
|
|
1342
|
+
await runMiddleware({ bucket: PublicDashboardRateLimitBucket.Read });
|
|
1343
|
+
|
|
1344
|
+
expect(loggerWarnMock.mock.calls.length).toBeGreaterThan(0);
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it("does not log once per attempt while refusing passwords in an outage", async () => {
|
|
1348
|
+
isConnectedMock.mockReturnValue(false);
|
|
1349
|
+
loggerErrorMock.mockClear();
|
|
1350
|
+
|
|
1351
|
+
for (let i: number = 0; i < 100; i++) {
|
|
1352
|
+
await runMiddleware({
|
|
1353
|
+
bucket: PublicDashboardRateLimitBucket.MasterPassword,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
expect(loggerErrorMock.mock.calls.length).toBeLessThanOrEqual(1);
|
|
1358
|
+
|
|
1359
|
+
/* Every one of them was still refused. */
|
|
1360
|
+
expect(sendErrorResponseMock).toHaveBeenCalledTimes(100);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it("derives its key from the request, not from the caller's header", async () => {
|
|
1364
|
+
const dashboardId: string = ObjectID.generate().toString();
|
|
1365
|
+
|
|
1366
|
+
/*
|
|
1367
|
+
* Same real client, forging a different left-hand entry each time. All
|
|
1368
|
+
* of it must land in one bucket.
|
|
1369
|
+
*/
|
|
1370
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT; i++) {
|
|
1371
|
+
await runMiddleware({
|
|
1372
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1373
|
+
request: buildRequest({
|
|
1374
|
+
params: { dashboardId },
|
|
1375
|
+
headers: { "x-forwarded-for": `10.0.0.${i % 250}, 203.0.113.7` },
|
|
1376
|
+
}),
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
sendErrorResponseMock.mockClear();
|
|
1381
|
+
|
|
1382
|
+
const result: { nextCalled: boolean } = await runMiddleware({
|
|
1383
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1384
|
+
request: buildRequest({
|
|
1385
|
+
params: { dashboardId },
|
|
1386
|
+
headers: { "x-forwarded-for": "1.2.3.4, 203.0.113.7" },
|
|
1387
|
+
}),
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
expect(result.nextCalled).toBe(false);
|
|
1391
|
+
});
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
/*
|
|
1396
|
+
* The budgets and the trusted-hop count are read from the environment at
|
|
1397
|
+
* module load, so these reload the module rather than calling into the
|
|
1398
|
+
* already-configured one.
|
|
1399
|
+
*/
|
|
1400
|
+
describe("PublicDashboardRateLimit configuration", () => {
|
|
1401
|
+
const originalEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
1402
|
+
|
|
1403
|
+
afterEach(() => {
|
|
1404
|
+
process.env = { ...originalEnv };
|
|
1405
|
+
jest.resetModules();
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
interface ReloadedModule {
|
|
1409
|
+
limiter: typeof PublicDashboardRateLimit;
|
|
1410
|
+
client: FakeRedisClient;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const reload: () => Promise<ReloadedModule> = async () => {
|
|
1414
|
+
jest.resetModules();
|
|
1415
|
+
|
|
1416
|
+
const redisModule: {
|
|
1417
|
+
default: { getClient: MockedFn; isConnected: MockedFn };
|
|
1418
|
+
} = (await import("../../../Server/Infrastructure/Redis")) as unknown as {
|
|
1419
|
+
default: { getClient: MockedFn; isConnected: MockedFn };
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
const limiterModule: { default: typeof PublicDashboardRateLimit } =
|
|
1423
|
+
(await import(
|
|
1424
|
+
"../../../Server/Middleware/PublicDashboardRateLimit"
|
|
1425
|
+
)) as unknown as { default: typeof PublicDashboardRateLimit };
|
|
1426
|
+
|
|
1427
|
+
const client: FakeRedisClient = new FakeRedisClient();
|
|
1428
|
+
|
|
1429
|
+
redisModule.default.getClient.mockReturnValue(client);
|
|
1430
|
+
redisModule.default.isConnected.mockReturnValue(true);
|
|
1431
|
+
|
|
1432
|
+
return { limiter: limiterModule.default, client };
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
it("honours a configured per-dashboard read limit", async () => {
|
|
1436
|
+
process.env["PUBLIC_DASHBOARD_RATE_LIMIT_PER_DASHBOARD_PER_WINDOW"] = "3";
|
|
1437
|
+
|
|
1438
|
+
const { limiter } = await reload();
|
|
1439
|
+
|
|
1440
|
+
for (let i: number = 0; i < 3; i++) {
|
|
1441
|
+
expect(
|
|
1442
|
+
(
|
|
1443
|
+
await limiter.consume({
|
|
1444
|
+
dashboardKey: "id:a",
|
|
1445
|
+
clientIp: "203.0.113.7",
|
|
1446
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1447
|
+
})
|
|
1448
|
+
).outcome,
|
|
1449
|
+
).toBe(PublicDashboardRateLimitOutcome.Allowed);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
expect(
|
|
1453
|
+
(
|
|
1454
|
+
await limiter.consume({
|
|
1455
|
+
dashboardKey: "id:a",
|
|
1456
|
+
clientIp: "203.0.113.7",
|
|
1457
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1458
|
+
})
|
|
1459
|
+
).outcome,
|
|
1460
|
+
).toBe(PublicDashboardRateLimitOutcome.RateLimited);
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
it("honours a configured per-address read ceiling", async () => {
|
|
1464
|
+
process.env["PUBLIC_DASHBOARD_RATE_LIMIT_PER_IP_PER_WINDOW"] = "4";
|
|
1465
|
+
|
|
1466
|
+
const { limiter } = await reload();
|
|
1467
|
+
|
|
1468
|
+
let rejected: boolean = false;
|
|
1469
|
+
|
|
1470
|
+
for (let i: number = 0; i < 6; i++) {
|
|
1471
|
+
const decision: PublicDashboardRateLimitDecision = await limiter.consume({
|
|
1472
|
+
dashboardKey: `id:rotating-${i}`,
|
|
1473
|
+
clientIp: "203.0.113.7",
|
|
1474
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
if (decision.outcome === PublicDashboardRateLimitOutcome.RateLimited) {
|
|
1478
|
+
rejected = true;
|
|
1479
|
+
expect(decision.scope).toBe(PublicDashboardRateLimitScope.Ip);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
expect(rejected).toBe(true);
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
it("honours a configured master password limit", async () => {
|
|
1487
|
+
process.env[
|
|
1488
|
+
"PUBLIC_DASHBOARD_MASTER_PASSWORD_RATE_LIMIT_PER_DASHBOARD_PER_WINDOW"
|
|
1489
|
+
] = "2";
|
|
1490
|
+
|
|
1491
|
+
const { limiter } = await reload();
|
|
1492
|
+
|
|
1493
|
+
for (let i: number = 0; i < 2; i++) {
|
|
1494
|
+
await limiter.consume({
|
|
1495
|
+
dashboardKey: "id:a",
|
|
1496
|
+
clientIp: "203.0.113.7",
|
|
1497
|
+
bucket: PublicDashboardRateLimitBucket.MasterPassword,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
expect(
|
|
1502
|
+
(
|
|
1503
|
+
await limiter.consume({
|
|
1504
|
+
dashboardKey: "id:a",
|
|
1505
|
+
clientIp: "203.0.113.7",
|
|
1506
|
+
bucket: PublicDashboardRateLimitBucket.MasterPassword,
|
|
1507
|
+
})
|
|
1508
|
+
).outcome,
|
|
1509
|
+
).toBe(PublicDashboardRateLimitOutcome.RateLimited);
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
it("falls back to the default when the value is not a number", async () => {
|
|
1513
|
+
process.env["PUBLIC_DASHBOARD_RATE_LIMIT_PER_DASHBOARD_PER_WINDOW"] =
|
|
1514
|
+
"not-a-number";
|
|
1515
|
+
|
|
1516
|
+
const { limiter } = await reload();
|
|
1517
|
+
|
|
1518
|
+
for (let i: number = 0; i < READ_PER_DASHBOARD_LIMIT; i++) {
|
|
1519
|
+
await limiter.consume({
|
|
1520
|
+
dashboardKey: "id:a",
|
|
1521
|
+
clientIp: "203.0.113.7",
|
|
1522
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
expect(
|
|
1527
|
+
(
|
|
1528
|
+
await limiter.consume({
|
|
1529
|
+
dashboardKey: "id:a",
|
|
1530
|
+
clientIp: "203.0.113.7",
|
|
1531
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1532
|
+
})
|
|
1533
|
+
).outcome,
|
|
1534
|
+
).toBe(PublicDashboardRateLimitOutcome.RateLimited);
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
it("falls back to the default when the value is zero or negative", async () => {
|
|
1538
|
+
process.env["PUBLIC_DASHBOARD_RATE_LIMIT_PER_DASHBOARD_PER_WINDOW"] = "0";
|
|
1539
|
+
|
|
1540
|
+
const { limiter } = await reload();
|
|
1541
|
+
|
|
1542
|
+
/* A limit of zero would refuse everything; the fallback must win. */
|
|
1543
|
+
expect(
|
|
1544
|
+
(
|
|
1545
|
+
await limiter.consume({
|
|
1546
|
+
dashboardKey: "id:a",
|
|
1547
|
+
clientIp: "203.0.113.7",
|
|
1548
|
+
bucket: PublicDashboardRateLimitBucket.Read,
|
|
1549
|
+
})
|
|
1550
|
+
).outcome,
|
|
1551
|
+
).toBe(PublicDashboardRateLimitOutcome.Allowed);
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
/*
|
|
1555
|
+
* Behind an extra load balancer the rightmost hop is the balancer, not the
|
|
1556
|
+
* viewer, and every client collapses into one bucket. Operators widen the
|
|
1557
|
+
* hop count to compensate.
|
|
1558
|
+
*
|
|
1559
|
+
* The knob is the INSTANCE-WIDE TRUSTED_PROXY_HOPS, shared with the
|
|
1560
|
+
* dashboard and status page IP allowlists. This limiter deliberately has no
|
|
1561
|
+
* private hop setting: an operator who set the shared one and left a private
|
|
1562
|
+
* one at its default would fix their allowlists while silently leaving this
|
|
1563
|
+
* limiter reading the balancer's address, refusing every viewer behind it.
|
|
1564
|
+
*/
|
|
1565
|
+
it("counts back the instance-wide trusted hop count", async () => {
|
|
1566
|
+
process.env["TRUSTED_PROXY_HOPS"] = "2";
|
|
1567
|
+
|
|
1568
|
+
const { limiter } = await reload();
|
|
1569
|
+
|
|
1570
|
+
expect(
|
|
1571
|
+
limiter.resolveClientIp(
|
|
1572
|
+
buildRequest({
|
|
1573
|
+
headers: { "x-forwarded-for": "203.0.113.7, 10.0.0.5, 10.0.0.6" },
|
|
1574
|
+
}),
|
|
1575
|
+
),
|
|
1576
|
+
).toBe("10.0.0.5");
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
it("defaults to a single trusted hop", async () => {
|
|
1580
|
+
delete process.env["TRUSTED_PROXY_HOPS"];
|
|
1581
|
+
|
|
1582
|
+
const { limiter } = await reload();
|
|
1583
|
+
|
|
1584
|
+
expect(
|
|
1585
|
+
limiter.resolveClientIp(
|
|
1586
|
+
buildRequest({
|
|
1587
|
+
headers: { "x-forwarded-for": "9.9.9.9, 203.0.113.7" },
|
|
1588
|
+
}),
|
|
1589
|
+
),
|
|
1590
|
+
).toBe("203.0.113.7");
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
/*
|
|
1594
|
+
* The trap this reconciliation exists to close: one setting must move both
|
|
1595
|
+
* controls together. If these ever diverge, a double-proxy deployment gets
|
|
1596
|
+
* working allowlists and a limiter that refuses everyone.
|
|
1597
|
+
*/
|
|
1598
|
+
it("honours the same setting the IP allowlists read", async () => {
|
|
1599
|
+
process.env["TRUSTED_PROXY_HOPS"] = "2";
|
|
1600
|
+
|
|
1601
|
+
const { limiter } = await reload();
|
|
1602
|
+
|
|
1603
|
+
const environmentConfig: { TrustedProxyHops: number } = (await import(
|
|
1604
|
+
"../../../Server/EnvironmentConfig"
|
|
1605
|
+
)) as unknown as {
|
|
1606
|
+
TrustedProxyHops: number;
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
expect(environmentConfig.TrustedProxyHops).toBe(2);
|
|
1610
|
+
|
|
1611
|
+
expect(
|
|
1612
|
+
limiter.resolveClientIp(
|
|
1613
|
+
buildRequest({
|
|
1614
|
+
headers: { "x-forwarded-for": "9.9.9.9, 203.0.113.7, 10.0.0.5" },
|
|
1615
|
+
}),
|
|
1616
|
+
),
|
|
1617
|
+
).toBe("203.0.113.7");
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
/*
|
|
1621
|
+
* The helper normalizes address forms, so one viewer cannot be split across
|
|
1622
|
+
* two buckets by arriving as an IPv4-mapped IPv6 literal on one request and
|
|
1623
|
+
* a bare IPv4 on the next.
|
|
1624
|
+
*/
|
|
1625
|
+
it("bills one viewer to one bucket regardless of address form", async () => {
|
|
1626
|
+
delete process.env["TRUSTED_PROXY_HOPS"];
|
|
1627
|
+
|
|
1628
|
+
const { limiter } = await reload();
|
|
1629
|
+
|
|
1630
|
+
const mapped: string = limiter.resolveClientIp(
|
|
1631
|
+
buildRequest({ headers: { "x-forwarded-for": "::ffff:203.0.113.7" } }),
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
const bare: string = limiter.resolveClientIp(
|
|
1635
|
+
buildRequest({ headers: { "x-forwarded-for": "203.0.113.7" } }),
|
|
1636
|
+
);
|
|
1637
|
+
|
|
1638
|
+
const withPort: string = limiter.resolveClientIp(
|
|
1639
|
+
buildRequest({ headers: { "x-forwarded-for": "203.0.113.7:51234" } }),
|
|
1640
|
+
);
|
|
1641
|
+
|
|
1642
|
+
expect(mapped).toBe(bare);
|
|
1643
|
+
expect(withPort).toBe(bare);
|
|
1644
|
+
});
|
|
1645
|
+
});
|