@oneuptime/common 12.0.2 → 12.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/CephCluster.ts +14 -0
- package/Models/DatabaseModels/CodeRepository.ts +14 -6
- package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +14 -0
- package/Models/DatabaseModels/DockerHost.ts +14 -0
- package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
- package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/IoTFleet.ts +14 -0
- package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
- package/Models/DatabaseModels/LogDropFilter.ts +9 -20
- package/Models/DatabaseModels/NetworkDevice.ts +14 -0
- package/Models/DatabaseModels/NetworkInterface.ts +18 -0
- package/Models/DatabaseModels/Project.ts +16 -2
- package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
- package/Models/DatabaseModels/Service.ts +30 -0
- package/Models/DatabaseModels/StatusPageMonitorRule.ts +911 -0
- package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
- package/Models/DatabaseModels/StatusPageResource.ts +73 -0
- package/Models/DatabaseModels/TraceDropFilter.ts +9 -20
- package/Models/DatabaseModels/User.ts +30 -0
- package/Server/API/AIAgentDataAPI.ts +31 -0
- package/Server/API/BaseAPI.ts +13 -2
- package/Server/API/DashboardAPI.ts +458 -12
- package/Server/API/GitHubAPI.ts +119 -283
- package/Server/API/UserAPI.ts +263 -1
- package/Server/EnvironmentConfig.ts +38 -4
- package/Server/Infrastructure/GlobalCache.ts +152 -0
- package/Server/Infrastructure/Postgres/DataSourceOptions.ts +16 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.ts +123 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +12 -0
- package/Server/Services/AlertStateTimelineService.ts +20 -0
- package/Server/Services/AnalyticsDatabaseService.ts +72 -1
- package/Server/Services/CephClusterService.ts +26 -86
- package/Server/Services/CloudResourceService.ts +23 -83
- package/Server/Services/CodeRepositoryService.ts +105 -2
- package/Server/Services/DatabaseService.ts +296 -2
- package/Server/Services/DockerHostService.ts +19 -79
- package/Server/Services/DockerSwarmClusterService.ts +27 -88
- package/Server/Services/HostService.ts +46 -99
- package/Server/Services/IncidentStateTimelineService.ts +20 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/IoTFleetService.ts +19 -79
- package/Server/Services/KubernetesClusterService.ts +17 -78
- package/Server/Services/LabelService.ts +57 -10
- package/Server/Services/MetricTypeService.ts +84 -0
- package/Server/Services/MonitorService.ts +89 -14
- package/Server/Services/MonitorStatusTimelineService.ts +29 -0
- package/Server/Services/MonitorTemplateService.ts +65 -0
- package/Server/Services/OpenTelemetryIngestService.ts +230 -26
- package/Server/Services/PodmanHostService.ts +19 -79
- package/Server/Services/ProxmoxClusterService.ts +23 -83
- package/Server/Services/RumApplicationService.ts +19 -80
- package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +21 -0
- package/Server/Services/ServerlessFunctionService.ts +24 -85
- package/Server/Services/ServiceService.ts +299 -73
- package/Server/Services/StatusPageMonitorRuleEngineService.ts +752 -0
- package/Server/Services/StatusPageMonitorRuleService.ts +471 -0
- package/Server/Services/TelemetryEntityRelationshipService.ts +1 -0
- package/Server/Services/TelemetryEntityService.ts +1 -0
- package/Server/Services/UserService.ts +184 -0
- package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
- package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
- package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
- package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +170 -39
- package/Server/Utils/Monitor/MonitorResource.ts +63 -36
- package/Server/Utils/Monitor/MonitorStatusTimeline.ts +36 -0
- package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +148 -127
- package/Server/Utils/Monitor/MonitorStepsReferenceExtractor.ts +434 -0
- package/Server/Utils/PasswordHash.ts +306 -0
- package/Server/Utils/SingleFlight.ts +83 -0
- package/Server/Utils/StatusPage/MonitorRulePatternValidator.ts +46 -0
- package/Server/Utils/Telemetry/EntityRegistry.ts +107 -26
- package/Server/Utils/Telemetry/ResourceHeartbeat.ts +314 -0
- package/Server/Utils/Telemetry/Telemetry.ts +351 -122
- package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
- package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
- package/Tests/App/Dashboard/WidgetCatalog.test.ts +2 -0
- package/Tests/Models/DatabaseModels/ColumnTransformerInvariants.test.ts +306 -0
- package/Tests/Models/DatabaseModels/NumberColumnDeserialization.test.ts +333 -0
- package/Tests/Models/DatabaseModels/StatusPageMonitorRuleModel.test.ts +190 -0
- package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
- package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
- package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
- package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +26 -0
- package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
- package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
- package/Tests/Server/API/Helpers.ts +6 -1
- package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
- package/Tests/Server/Infrastructure/GlobalCache.test.ts +354 -0
- package/Tests/Server/Infrastructure/PostgresLockTimeoutOptions.test.ts +181 -0
- package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
- package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
- package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
- package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
- package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
- package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
- package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlocked.test.ts +342 -0
- package/Tests/Server/Services/DropFilterSaveValidation.test.ts +245 -0
- package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +163 -15
- package/Tests/Server/Services/MetricTypeAttachServices.test.ts +222 -0
- package/Tests/Server/Services/MissingReferenceWriteGuard.test.ts +614 -0
- package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +23 -0
- package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
- package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +305 -56
- package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
- package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
- package/Tests/Server/Services/ServiceUpdateLastSeenSplitGates.test.ts +705 -0
- package/Tests/Server/Services/StatusPageMonitorRuleEngineService.test.ts +1719 -0
- package/Tests/Server/Services/StatusPageMonitorRuleHooks.test.ts +1212 -0
- package/Tests/Server/Services/StatusPageMonitorRuleMonitorCreateHook.test.ts +228 -0
- package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
- package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
- package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
- package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
- package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +267 -0
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
- package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
- package/Tests/Server/Utils/Database/ProjectScopedReferenceValidator.test.ts +260 -13
- package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +302 -0
- package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
- package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
- package/Tests/Server/Utils/MetricTypeIndexConvergence.test.ts +598 -0
- package/Tests/Server/Utils/Monitor/MonitorStatusTimelineUnusableStatus.test.ts +154 -0
- package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +638 -92
- package/Tests/Server/Utils/Monitor/MonitorStepsReferenceExtractor.test.ts +724 -0
- package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
- package/Tests/Server/Utils/SingleFlight.test.ts +221 -0
- package/Tests/Types/Dashboard/NetworkDashboardTemplate.test.ts +793 -0
- package/Tests/Types/Database/BigIntColumnTransformer.test.ts +147 -0
- package/Tests/Types/Database/ColumnLength.test.ts +7 -1
- package/Tests/Types/Database/DatabasePropertyTransformer.test.ts +140 -0
- package/Tests/Types/Database/NumericColumnValue.test.ts +127 -0
- package/Tests/Types/Decimal.test.ts +37 -0
- package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
- package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
- package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
- package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
- package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
- package/Tests/Types/Monitor/SnmpVersionParsing.test.ts +152 -0
- package/Tests/Types/Port.test.ts +38 -0
- package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
- package/Tests/UI/Components/LogsHistogram.test.tsx +276 -0
- package/Tests/UI/Components/UseLiveLogsRefresh.test.tsx +489 -0
- package/Tests/UI/Components/UseLogsHistogram.test.tsx +500 -0
- package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
- package/Tests/Utils/Dashboard/Components/DashboardComponentsUtil.test.ts +120 -0
- package/Tests/Utils/Dashboard/DashboardNetworkMapComponent.test.ts +327 -0
- package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
- package/Types/Dashboard/DashboardComponentType.ts +1 -0
- package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
- package/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.ts +40 -0
- package/Types/Dashboard/DashboardTemplates.ts +397 -0
- package/Types/Database/BigIntColumnTransformer.ts +74 -0
- package/Types/Database/ColumnLength.ts +9 -1
- package/Types/Database/DatabaseProperty.ts +26 -0
- package/Types/Database/NumericColumnValue.ts +128 -0
- package/Types/Database/TableColumn.ts +11 -0
- package/Types/Database/UnsynchronizedIndex.ts +48 -0
- package/Types/Decimal.ts +16 -4
- package/Types/HashedString.ts +139 -4
- package/Types/Permission.ts +48 -0
- package/Types/Port.ts +16 -4
- package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
- package/UI/Components/LogsViewer/useLiveLogsRefresh.ts +86 -0
- package/UI/Components/LogsViewer/useLogsHistogram.ts +113 -0
- package/UI/Components/Navbar/NavBar.tsx +12 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
- package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
- package/Utils/Dashboard/Components/DashboardNetworkMapComponent.ts +136 -0
- package/Utils/Dashboard/Components/Index.ts +7 -0
- package/Utils/TeamMembersByProject.ts +237 -0
- package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
- package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +12 -0
- package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/LogDropFilter.js +9 -20
- package/build/dist/Models/DatabaseModels/LogDropFilter.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +16 -2
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Service.js +28 -1
- package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js +925 -0
- package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageResource.js +72 -0
- package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TraceDropFilter.js +9 -20
- package/build/dist/Models/DatabaseModels/TraceDropFilter.js.map +1 -1
- package/build/dist/Models/DatabaseModels/User.js +32 -0
- package/build/dist/Models/DatabaseModels/User.js.map +1 -1
- package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
- package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
- package/build/dist/Server/API/BaseAPI.js +11 -1
- package/build/dist/Server/API/BaseAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +343 -10
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/GitHubAPI.js +94 -167
- package/build/dist/Server/API/GitHubAPI.js.map +1 -1
- package/build/dist/Server/API/UserAPI.js +187 -2
- package/build/dist/Server/API/UserAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +34 -4
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/GlobalCache.js +120 -0
- package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +5 -3
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js +48 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AlertStateTimelineService.js +19 -0
- package/build/dist/Server/Services/AlertStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/CephClusterService.js +26 -76
- package/build/dist/Server/Services/CephClusterService.js.map +1 -1
- package/build/dist/Server/Services/CloudResourceService.js +23 -73
- package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
- package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
- package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +246 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DockerHostService.js +19 -69
- package/build/dist/Server/Services/DockerHostService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmClusterService.js +27 -78
- package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
- package/build/dist/Server/Services/HostService.js +58 -102
- package/build/dist/Server/Services/HostService.js.map +1 -1
- package/build/dist/Server/Services/IncidentStateTimelineService.js +19 -0
- package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/IoTFleetService.js +19 -69
- package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesClusterService.js +17 -68
- package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
- package/build/dist/Server/Services/LabelService.js +42 -7
- package/build/dist/Server/Services/LabelService.js.map +1 -1
- package/build/dist/Server/Services/MetricTypeService.js +74 -0
- package/build/dist/Server/Services/MetricTypeService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +81 -9
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/MonitorStatusTimelineService.js +28 -0
- package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
- package/build/dist/Server/Services/MonitorTemplateService.js +60 -0
- package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/PodmanHostService.js +19 -69
- package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxClusterService.js +23 -73
- package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
- package/build/dist/Server/Services/RumApplicationService.js +19 -70
- package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +20 -0
- package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/ServerlessFunctionService.js +24 -75
- package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
- package/build/dist/Server/Services/ServiceService.js +236 -61
- package/build/dist/Server/Services/ServiceService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +603 -0
- package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/StatusPageMonitorRuleService.js +401 -0
- package/build/dist/Server/Services/StatusPageMonitorRuleService.js.map +1 -0
- package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +1 -0
- package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
- package/build/dist/Server/Services/TelemetryEntityService.js +1 -0
- package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +165 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
- package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +105 -29
- package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +56 -32
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +30 -3
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +110 -98
- package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js +312 -0
- package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js.map +1 -0
- package/build/dist/Server/Utils/PasswordHash.js +237 -0
- package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
- package/build/dist/Server/Utils/SingleFlight.js +76 -0
- package/build/dist/Server/Utils/SingleFlight.js.map +1 -0
- package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js +29 -0
- package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +78 -19
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js +160 -0
- package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/Telemetry.js +279 -104
- package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
- package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js +2 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardTemplates.js +368 -0
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/Database/BigIntColumnTransformer.js +66 -0
- package/build/dist/Types/Database/BigIntColumnTransformer.js.map +1 -0
- package/build/dist/Types/Database/ColumnLength.js +9 -1
- package/build/dist/Types/Database/ColumnLength.js.map +1 -1
- package/build/dist/Types/Database/DatabaseProperty.js +25 -0
- package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
- package/build/dist/Types/Database/NumericColumnValue.js +97 -0
- package/build/dist/Types/Database/NumericColumnValue.js.map +1 -0
- package/build/dist/Types/Database/TableColumn.js.map +1 -1
- package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
- package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
- package/build/dist/Types/Decimal.js +14 -3
- package/build/dist/Types/Decimal.js.map +1 -1
- package/build/dist/Types/HashedString.js +95 -5
- package/build/dist/Types/HashedString.js.map +1 -1
- package/build/dist/Types/Permission.js +42 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Port.js +15 -5
- package/build/dist/Types/Port.js.map +1 -1
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js +44 -0
- package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js.map +1 -0
- package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js +75 -0
- package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js.map +1 -0
- package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js +114 -0
- package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
- package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
- package/build/dist/Utils/TeamMembersByProject.js +145 -0
- package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1296 @@
|
|
|
1
|
+
import Dashboard from "../../../Models/DatabaseModels/Dashboard";
|
|
2
|
+
import DashboardAPI from "../../../Server/API/DashboardAPI";
|
|
3
|
+
import DashboardService from "../../../Server/Services/DashboardService";
|
|
4
|
+
import MetricService from "../../../Server/Services/MetricService";
|
|
5
|
+
import {
|
|
6
|
+
ExpressRequest,
|
|
7
|
+
ExpressResponse,
|
|
8
|
+
NextFunction,
|
|
9
|
+
} from "../../../Server/Utils/Express";
|
|
10
|
+
import Response from "../../../Server/Utils/Response";
|
|
11
|
+
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
12
|
+
import Includes from "../../../Types/BaseDatabase/Includes";
|
|
13
|
+
import DashboardComponentType from "../../../Types/Dashboard/DashboardComponentType";
|
|
14
|
+
import { DashboardVariableType } from "../../../Types/Dashboard/DashboardVariable";
|
|
15
|
+
import DashboardViewConfig from "../../../Types/Dashboard/DashboardViewConfig";
|
|
16
|
+
import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
|
|
17
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
18
|
+
import NotAuthenticatedException from "../../../Types/Exception/NotAuthenticatedException";
|
|
19
|
+
import NotFoundException from "../../../Types/Exception/NotFoundException";
|
|
20
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
21
|
+
import JSONFunctions from "../../../Types/JSONFunctions";
|
|
22
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
23
|
+
import { mockRouter } from "./Helpers";
|
|
24
|
+
import {
|
|
25
|
+
afterEach,
|
|
26
|
+
beforeAll,
|
|
27
|
+
beforeEach,
|
|
28
|
+
describe,
|
|
29
|
+
expect,
|
|
30
|
+
it,
|
|
31
|
+
} from "@jest/globals";
|
|
32
|
+
|
|
33
|
+
jest.mock("../../../Server/Utils/Express", () => {
|
|
34
|
+
return {
|
|
35
|
+
getRouter: () => {
|
|
36
|
+
return mockRouter;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
jest.mock("../../../Server/Utils/Response", () => {
|
|
42
|
+
return {
|
|
43
|
+
sendEntityArrayResponse: jest.fn().mockImplementation((...args: []) => {
|
|
44
|
+
return args;
|
|
45
|
+
}),
|
|
46
|
+
sendJsonObjectResponse: jest.fn().mockImplementation((...args: []) => {
|
|
47
|
+
return args;
|
|
48
|
+
}),
|
|
49
|
+
sendEmptySuccessResponse: jest.fn(),
|
|
50
|
+
sendEntityResponse: jest.fn().mockImplementation((...args: []) => {
|
|
51
|
+
return args;
|
|
52
|
+
}),
|
|
53
|
+
sendErrorResponse: jest.fn().mockImplementation((...args: []) => {
|
|
54
|
+
return args;
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const METRICS_AGGREGATE_ROUTE: string =
|
|
60
|
+
"/dashboard/metrics-aggregate/:dashboardId";
|
|
61
|
+
|
|
62
|
+
const CHARTED_METRIC_NAME: string = "system.cpu.utilization";
|
|
63
|
+
|
|
64
|
+
const CHARTED_GROUP_BY_KEY: string = "resource.host.name";
|
|
65
|
+
|
|
66
|
+
describe("DashboardAPI public metrics-aggregate", () => {
|
|
67
|
+
let dashboardId: ObjectID;
|
|
68
|
+
let projectId: ObjectID;
|
|
69
|
+
let dashboard: Dashboard;
|
|
70
|
+
let mockResponse: ExpressResponse;
|
|
71
|
+
let nextFunction: NextFunction;
|
|
72
|
+
|
|
73
|
+
beforeAll(() => {
|
|
74
|
+
mockRouter.routes.length = 0;
|
|
75
|
+
new DashboardAPI();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
type MetricWidgetSpec = {
|
|
79
|
+
metricName: string;
|
|
80
|
+
groupByAttributeKeys?: Array<string> | undefined;
|
|
81
|
+
groupByAttributes?: Array<JSONObject> | undefined;
|
|
82
|
+
groupBy?: JSONObject | undefined;
|
|
83
|
+
/*
|
|
84
|
+
* `metricQueryData.filterData.attributes` — the attribute filter a chart
|
|
85
|
+
* widget persists when the author pins a value in the query editor.
|
|
86
|
+
*/
|
|
87
|
+
attributes?: JSONObject | undefined;
|
|
88
|
+
/*
|
|
89
|
+
* `arguments.attributeFilters` — the table widget's widget-level filter,
|
|
90
|
+
* spread into every column's query by resolveQueries().
|
|
91
|
+
*/
|
|
92
|
+
attributeFilters?: JSONObject | undefined;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type BuildViewConfigFunction = (
|
|
96
|
+
widgets: Array<MetricWidgetSpec>,
|
|
97
|
+
variableAttributeKeys?: Array<string> | undefined,
|
|
98
|
+
) => DashboardViewConfig;
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* A stored chart widget, shaped the way the canvas persists it: the query
|
|
102
|
+
* lives under `arguments.metricQueryConfigs[].metricQueryData`.
|
|
103
|
+
*/
|
|
104
|
+
const buildViewConfig: BuildViewConfigFunction = (
|
|
105
|
+
widgets: Array<MetricWidgetSpec>,
|
|
106
|
+
variableAttributeKeys?: Array<string> | undefined,
|
|
107
|
+
): DashboardViewConfig => {
|
|
108
|
+
return {
|
|
109
|
+
_type: "DashboardViewConfig",
|
|
110
|
+
heightInDashboardUnits: 24,
|
|
111
|
+
variables: (variableAttributeKeys || []).map(
|
|
112
|
+
(attributeKey: string, index: number): JSONObject => {
|
|
113
|
+
return {
|
|
114
|
+
id: ObjectID.generate().toString(),
|
|
115
|
+
name: `variable-${index}`,
|
|
116
|
+
label: `Variable ${index}`,
|
|
117
|
+
type: DashboardVariableType.TelemetryAttribute,
|
|
118
|
+
attributeKey: attributeKey,
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
),
|
|
122
|
+
components: widgets.map(
|
|
123
|
+
(widget: MetricWidgetSpec, index: number): JSONObject => {
|
|
124
|
+
return {
|
|
125
|
+
_type: "DashboardComponent",
|
|
126
|
+
componentId: ObjectID.generate().toString(),
|
|
127
|
+
componentType: DashboardComponentType.Chart,
|
|
128
|
+
topInDashboardUnits: index,
|
|
129
|
+
leftInDashboardUnits: 0,
|
|
130
|
+
widthInDashboardUnits: 12,
|
|
131
|
+
heightInDashboardUnits: 6,
|
|
132
|
+
arguments: {
|
|
133
|
+
...(widget.groupByAttributes
|
|
134
|
+
? { groupByAttributes: widget.groupByAttributes }
|
|
135
|
+
: {}),
|
|
136
|
+
...(widget.attributeFilters
|
|
137
|
+
? { attributeFilters: widget.attributeFilters }
|
|
138
|
+
: {}),
|
|
139
|
+
metricQueryConfigs: [
|
|
140
|
+
{
|
|
141
|
+
metricQueryData: {
|
|
142
|
+
filterData: {
|
|
143
|
+
metricName: widget.metricName,
|
|
144
|
+
...(widget.attributes
|
|
145
|
+
? { attributes: widget.attributes }
|
|
146
|
+
: {}),
|
|
147
|
+
},
|
|
148
|
+
...(widget.groupByAttributeKeys
|
|
149
|
+
? { groupByAttributeKeys: widget.groupByAttributeKeys }
|
|
150
|
+
: {}),
|
|
151
|
+
...(widget.groupBy ? { groupBy: widget.groupBy } : {}),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
),
|
|
159
|
+
} as unknown as DashboardViewConfig;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
type SetWidgetsFunction = (
|
|
163
|
+
widgets: Array<MetricWidgetSpec>,
|
|
164
|
+
variableAttributeKeys?: Array<string> | undefined,
|
|
165
|
+
) => void;
|
|
166
|
+
|
|
167
|
+
const setWidgets: SetWidgetsFunction = (
|
|
168
|
+
widgets: Array<MetricWidgetSpec>,
|
|
169
|
+
variableAttributeKeys?: Array<string> | undefined,
|
|
170
|
+
): void => {
|
|
171
|
+
dashboard.dashboardViewConfig = buildViewConfig(
|
|
172
|
+
widgets,
|
|
173
|
+
variableAttributeKeys,
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
type BuildAggregateByFunction = (overrides?: JSONObject) => JSONObject;
|
|
178
|
+
|
|
179
|
+
const buildAggregateBy: BuildAggregateByFunction = (
|
|
180
|
+
overrides?: JSONObject,
|
|
181
|
+
): JSONObject => {
|
|
182
|
+
return {
|
|
183
|
+
query: {
|
|
184
|
+
name: CHARTED_METRIC_NAME,
|
|
185
|
+
},
|
|
186
|
+
aggregateColumnName: "value",
|
|
187
|
+
aggregationType: "Avg",
|
|
188
|
+
aggregationTimestampColumnName: "time",
|
|
189
|
+
startTimestamp: "2026-01-01T00:00:00.000Z",
|
|
190
|
+
endTimestamp: "2026-01-02T00:00:00.000Z",
|
|
191
|
+
limit: 100,
|
|
192
|
+
skip: 0,
|
|
193
|
+
...(overrides || {}),
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
type CallRouteFunction = (body?: JSONObject) => Promise<void>;
|
|
198
|
+
|
|
199
|
+
const callRoute: CallRouteFunction = async (
|
|
200
|
+
body?: JSONObject,
|
|
201
|
+
): Promise<void> => {
|
|
202
|
+
const request: ExpressRequest = {
|
|
203
|
+
params: {
|
|
204
|
+
dashboardId: dashboardId.toString(),
|
|
205
|
+
},
|
|
206
|
+
body: body === undefined ? { aggregateBy: buildAggregateBy() } : body,
|
|
207
|
+
query: {},
|
|
208
|
+
cookies: {},
|
|
209
|
+
headers: {},
|
|
210
|
+
socket: {},
|
|
211
|
+
ips: [],
|
|
212
|
+
} as unknown as ExpressRequest;
|
|
213
|
+
|
|
214
|
+
await mockRouter
|
|
215
|
+
.match("post", METRICS_AGGREGATE_ROUTE)
|
|
216
|
+
.handlerFunction(request, mockResponse, nextFunction);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
type CallWithAggregateFunction = (overrides?: JSONObject) => Promise<void>;
|
|
220
|
+
|
|
221
|
+
const callWithAggregate: CallWithAggregateFunction = async (
|
|
222
|
+
overrides?: JSONObject,
|
|
223
|
+
): Promise<void> => {
|
|
224
|
+
await callRoute({ aggregateBy: buildAggregateBy(overrides) });
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
type GetAggregateArgsFunction = () => JSONObject;
|
|
228
|
+
|
|
229
|
+
const getAggregateArgs: GetAggregateArgsFunction = (): JSONObject => {
|
|
230
|
+
const calls: Array<Array<unknown>> = (
|
|
231
|
+
MetricService.aggregateBy as jest.Mock
|
|
232
|
+
).mock.calls as Array<Array<unknown>>;
|
|
233
|
+
|
|
234
|
+
expect(calls.length).toBe(1);
|
|
235
|
+
|
|
236
|
+
return calls[0]![0] as JSONObject;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
type GetThrownErrorFunction = () => unknown;
|
|
240
|
+
|
|
241
|
+
const getThrownError: GetThrownErrorFunction = (): unknown => {
|
|
242
|
+
const calls: Array<Array<unknown>> = (nextFunction as jest.Mock).mock
|
|
243
|
+
.calls as Array<Array<unknown>>;
|
|
244
|
+
|
|
245
|
+
expect(calls.length).toBe(1);
|
|
246
|
+
|
|
247
|
+
return calls[0]![0];
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
type ExpectNothingAggregatedFunction = () => void;
|
|
251
|
+
|
|
252
|
+
const expectNothingAggregated: ExpectNothingAggregatedFunction = (): void => {
|
|
253
|
+
expect(MetricService.aggregateBy).not.toHaveBeenCalled();
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
jest.clearAllMocks();
|
|
258
|
+
|
|
259
|
+
dashboardId = ObjectID.generate();
|
|
260
|
+
projectId = ObjectID.generate();
|
|
261
|
+
|
|
262
|
+
dashboard = new Dashboard();
|
|
263
|
+
dashboard.id = dashboardId;
|
|
264
|
+
dashboard.projectId = projectId;
|
|
265
|
+
dashboard.isPublicDashboard = true;
|
|
266
|
+
dashboard.enableMasterPassword = false;
|
|
267
|
+
setWidgets([{ metricName: CHARTED_METRIC_NAME }]);
|
|
268
|
+
|
|
269
|
+
jest.spyOn(DashboardService, "findOneById").mockResolvedValue(dashboard);
|
|
270
|
+
|
|
271
|
+
jest.spyOn(MetricService, "aggregateBy").mockResolvedValue({ data: [] });
|
|
272
|
+
|
|
273
|
+
mockResponse = {
|
|
274
|
+
cookie: jest.fn(),
|
|
275
|
+
send: jest.fn(),
|
|
276
|
+
json: jest.fn(),
|
|
277
|
+
status: jest.fn().mockReturnThis(),
|
|
278
|
+
} as unknown as ExpressResponse;
|
|
279
|
+
|
|
280
|
+
nextFunction = jest.fn();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
afterEach(() => {
|
|
284
|
+
jest.restoreAllMocks();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("metric allowlist", () => {
|
|
288
|
+
it("aggregates a metric the dashboard charts", async () => {
|
|
289
|
+
await callWithAggregate();
|
|
290
|
+
|
|
291
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
292
|
+
expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("refuses a metric the dashboard does not chart", async () => {
|
|
296
|
+
await callWithAggregate({ query: { name: "billing.revenue.total" } });
|
|
297
|
+
|
|
298
|
+
const error: unknown = getThrownError();
|
|
299
|
+
|
|
300
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
301
|
+
expect((error as BadDataException).message).toBe(
|
|
302
|
+
"This metric is not part of this dashboard.",
|
|
303
|
+
);
|
|
304
|
+
expectNothingAggregated();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("refuses a non-string metric name used as an operator", async () => {
|
|
308
|
+
await callWithAggregate({
|
|
309
|
+
query: { name: { $ne: "nothing" } as unknown as string },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
313
|
+
expectNothingAggregated();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("requires an aggregateBy body", async () => {
|
|
317
|
+
await callRoute({});
|
|
318
|
+
|
|
319
|
+
const error: unknown = getThrownError();
|
|
320
|
+
|
|
321
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
322
|
+
expect((error as BadDataException).message).toBe(
|
|
323
|
+
"aggregateBy is required.",
|
|
324
|
+
);
|
|
325
|
+
expectNothingAggregated();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("group-by attribute allowlist (GHSA-w332-x78m-vf3v, related)", () => {
|
|
330
|
+
it("refuses to group an allowlisted metric by an attribute the widget never renders", async () => {
|
|
331
|
+
/*
|
|
332
|
+
* Grouped results echo each group's key values back to the caller, so
|
|
333
|
+
* an unrestricted groupByAttributeKeys turns a charted metric into a
|
|
334
|
+
* reader for arbitrary attribute values.
|
|
335
|
+
*/
|
|
336
|
+
setWidgets([
|
|
337
|
+
{
|
|
338
|
+
metricName: CHARTED_METRIC_NAME,
|
|
339
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
|
|
340
|
+
},
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
await callWithAggregate({ groupByAttributeKeys: ["enduser.id"] });
|
|
344
|
+
|
|
345
|
+
const error: unknown = getThrownError();
|
|
346
|
+
|
|
347
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
348
|
+
expect((error as BadDataException).message).toBe(
|
|
349
|
+
"This attribute is not part of this dashboard.",
|
|
350
|
+
);
|
|
351
|
+
expectNothingAggregated();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("refuses any group-by attribute at all when the widget groups by none", async () => {
|
|
355
|
+
for (const attributeKey of [
|
|
356
|
+
"user.email",
|
|
357
|
+
"http.url",
|
|
358
|
+
"db.statement",
|
|
359
|
+
"resource.host.name",
|
|
360
|
+
]) {
|
|
361
|
+
jest.clearAllMocks();
|
|
362
|
+
setWidgets([{ metricName: CHARTED_METRIC_NAME }]);
|
|
363
|
+
|
|
364
|
+
await callWithAggregate({ groupByAttributeKeys: [attributeKey] });
|
|
365
|
+
|
|
366
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
367
|
+
expectNothingAggregated();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("allows the attribute keys the widget is configured to group by", async () => {
|
|
372
|
+
setWidgets([
|
|
373
|
+
{
|
|
374
|
+
metricName: CHARTED_METRIC_NAME,
|
|
375
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY, "disk.device"],
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
|
|
379
|
+
await callWithAggregate({
|
|
380
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY, "disk.device"],
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
384
|
+
expect(getAggregateArgs()["groupByAttributeKeys"]).toEqual([
|
|
385
|
+
CHARTED_GROUP_BY_KEY,
|
|
386
|
+
"disk.device",
|
|
387
|
+
]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("allows keys configured through the table widget's groupByAttributes form", async () => {
|
|
391
|
+
setWidgets([
|
|
392
|
+
{
|
|
393
|
+
metricName: CHARTED_METRIC_NAME,
|
|
394
|
+
groupByAttributes: [{ key: "resource.service.name" }],
|
|
395
|
+
},
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
await callWithAggregate({
|
|
399
|
+
groupByAttributeKeys: ["resource.service.name"],
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
403
|
+
expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("rejects a request that smuggles one forbidden key in beside allowed ones", async () => {
|
|
407
|
+
setWidgets([
|
|
408
|
+
{
|
|
409
|
+
metricName: CHARTED_METRIC_NAME,
|
|
410
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
|
|
411
|
+
},
|
|
412
|
+
]);
|
|
413
|
+
|
|
414
|
+
await callWithAggregate({
|
|
415
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY, "user.email"],
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
419
|
+
expectNothingAggregated();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("rejects a groupByAttributeKeys that is not an array of strings", async () => {
|
|
423
|
+
setWidgets([
|
|
424
|
+
{
|
|
425
|
+
metricName: CHARTED_METRIC_NAME,
|
|
426
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
|
|
427
|
+
},
|
|
428
|
+
]);
|
|
429
|
+
|
|
430
|
+
const badValues: Array<unknown> = [
|
|
431
|
+
CHARTED_GROUP_BY_KEY,
|
|
432
|
+
42,
|
|
433
|
+
{ 0: CHARTED_GROUP_BY_KEY },
|
|
434
|
+
[42],
|
|
435
|
+
[null],
|
|
436
|
+
[{ key: CHARTED_GROUP_BY_KEY }],
|
|
437
|
+
];
|
|
438
|
+
|
|
439
|
+
for (const badValue of badValues) {
|
|
440
|
+
jest.clearAllMocks();
|
|
441
|
+
|
|
442
|
+
await callWithAggregate({
|
|
443
|
+
groupByAttributeKeys: badValue as Array<string>,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
447
|
+
expectNothingAggregated();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("leaves an ungrouped request untouched", async () => {
|
|
452
|
+
await callWithAggregate();
|
|
453
|
+
|
|
454
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
455
|
+
expect(getAggregateArgs()["groupByAttributeKeys"]).toBeUndefined();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("accepts an explicitly empty group-by list", async () => {
|
|
459
|
+
await callWithAggregate({ groupByAttributeKeys: [] });
|
|
460
|
+
|
|
461
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
462
|
+
expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
describe("group-by column allowlist", () => {
|
|
467
|
+
it("refuses to hand back the whole attribute map of a charted metric", async () => {
|
|
468
|
+
/*
|
|
469
|
+
* `groupBy: { attributes: true }` groups on the ENTIRE attribute map
|
|
470
|
+
* and echoes it back in every result row — a project-wide attribute
|
|
471
|
+
* dump for any metric the dashboard happens to chart.
|
|
472
|
+
*/
|
|
473
|
+
await callWithAggregate({ groupBy: { attributes: true } });
|
|
474
|
+
|
|
475
|
+
const error: unknown = getThrownError();
|
|
476
|
+
|
|
477
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
478
|
+
expect((error as BadDataException).message).toBe(
|
|
479
|
+
"This grouping is not part of this dashboard.",
|
|
480
|
+
);
|
|
481
|
+
expectNothingAggregated();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("refuses a column the widget does not group by", async () => {
|
|
485
|
+
setWidgets([
|
|
486
|
+
{
|
|
487
|
+
metricName: CHARTED_METRIC_NAME,
|
|
488
|
+
groupBy: { serviceId: true },
|
|
489
|
+
},
|
|
490
|
+
]);
|
|
491
|
+
|
|
492
|
+
await callWithAggregate({ groupBy: { attributes: true } });
|
|
493
|
+
|
|
494
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
495
|
+
expectNothingAggregated();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("allows the column the widget is configured to group by", async () => {
|
|
499
|
+
setWidgets([
|
|
500
|
+
{
|
|
501
|
+
metricName: CHARTED_METRIC_NAME,
|
|
502
|
+
groupBy: { serviceId: true },
|
|
503
|
+
},
|
|
504
|
+
]);
|
|
505
|
+
|
|
506
|
+
await callWithAggregate({ groupBy: { serviceId: true } });
|
|
507
|
+
|
|
508
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
509
|
+
expect(getAggregateArgs()["groupBy"]).toEqual({ serviceId: true });
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("does not treat a disabled group-by column as configured", async () => {
|
|
513
|
+
setWidgets([
|
|
514
|
+
{
|
|
515
|
+
metricName: CHARTED_METRIC_NAME,
|
|
516
|
+
groupBy: { attributes: false },
|
|
517
|
+
},
|
|
518
|
+
]);
|
|
519
|
+
|
|
520
|
+
await callWithAggregate({ groupBy: { attributes: true } });
|
|
521
|
+
|
|
522
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
523
|
+
expectNothingAggregated();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("rejects a groupBy that is not an object", async () => {
|
|
527
|
+
const badValues: Array<unknown> = [
|
|
528
|
+
"attributes",
|
|
529
|
+
["attributes"],
|
|
530
|
+
42,
|
|
531
|
+
true,
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
for (const badValue of badValues) {
|
|
535
|
+
jest.clearAllMocks();
|
|
536
|
+
|
|
537
|
+
await callWithAggregate({ groupBy: badValue as JSONObject });
|
|
538
|
+
|
|
539
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
540
|
+
expectNothingAggregated();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("accepts an explicitly empty groupBy", async () => {
|
|
545
|
+
await callWithAggregate({ groupBy: {} });
|
|
546
|
+
|
|
547
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
548
|
+
expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("does not let one widget's grouping unlock another widget's metric", async () => {
|
|
552
|
+
/*
|
|
553
|
+
* The allowlists are dashboard-wide by design (the config is a single
|
|
554
|
+
* tenant-owned document), but neither may be widened by the client.
|
|
555
|
+
*/
|
|
556
|
+
setWidgets([
|
|
557
|
+
{ metricName: CHARTED_METRIC_NAME },
|
|
558
|
+
{
|
|
559
|
+
metricName: "system.memory.usage",
|
|
560
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
|
|
561
|
+
},
|
|
562
|
+
]);
|
|
563
|
+
|
|
564
|
+
await callWithAggregate({
|
|
565
|
+
query: { name: "system.disk.io" },
|
|
566
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
570
|
+
expectNothingAggregated();
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
describe("query filter allowlist", () => {
|
|
575
|
+
/*
|
|
576
|
+
* Pinning `query.name` left the rest of the client's query merged in.
|
|
577
|
+
* An `attributes` filter on an arbitrary key is a blind oracle: the
|
|
578
|
+
* caller cannot see the values, but "did this aggregation return rows"
|
|
579
|
+
* leaks them a character at a time through operators like StartsWith.
|
|
580
|
+
* MetricService deliberately leaves the pre-aggregated fast path when an
|
|
581
|
+
* attributes filter is present, so the probe runs against raw rows.
|
|
582
|
+
*/
|
|
583
|
+
it("refuses an attributes filter on a key the dashboard never filters by", async () => {
|
|
584
|
+
await callWithAggregate({
|
|
585
|
+
query: {
|
|
586
|
+
name: CHARTED_METRIC_NAME,
|
|
587
|
+
attributes: {
|
|
588
|
+
"enduser.id": { _type: "StartsWith", value: "a" },
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const error: unknown = getThrownError();
|
|
594
|
+
|
|
595
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
596
|
+
expect((error as BadDataException).message).toBe(
|
|
597
|
+
"This attribute is not part of this dashboard.",
|
|
598
|
+
);
|
|
599
|
+
expectNothingAggregated();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("refuses every operator shape usable as a blind oracle", async () => {
|
|
603
|
+
const operatorTypes: Array<string> = [
|
|
604
|
+
"Search",
|
|
605
|
+
"StartsWith",
|
|
606
|
+
"EndsWith",
|
|
607
|
+
"NotContains",
|
|
608
|
+
"EqualTo",
|
|
609
|
+
"NotEqual",
|
|
610
|
+
"Includes",
|
|
611
|
+
];
|
|
612
|
+
|
|
613
|
+
for (const operatorType of operatorTypes) {
|
|
614
|
+
jest.clearAllMocks();
|
|
615
|
+
|
|
616
|
+
await callWithAggregate({
|
|
617
|
+
query: {
|
|
618
|
+
name: CHARTED_METRIC_NAME,
|
|
619
|
+
attributes: {
|
|
620
|
+
"user.email": { _type: operatorType, value: "a" },
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
626
|
+
expectNothingAggregated();
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("forwards an attributes filter on a key the widget itself filters by", async () => {
|
|
631
|
+
setWidgets([
|
|
632
|
+
{
|
|
633
|
+
metricName: CHARTED_METRIC_NAME,
|
|
634
|
+
attributes: { "host.name": "web-1" },
|
|
635
|
+
},
|
|
636
|
+
]);
|
|
637
|
+
|
|
638
|
+
await callWithAggregate({
|
|
639
|
+
query: {
|
|
640
|
+
name: CHARTED_METRIC_NAME,
|
|
641
|
+
attributes: { "host.name": "web-2" },
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
646
|
+
|
|
647
|
+
const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
|
|
648
|
+
|
|
649
|
+
expect((query["attributes"] as JSONObject)["host.name"]).toBe("web-2");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("forwards an attributes filter on a key only a dashboard variable binds to", async () => {
|
|
653
|
+
/*
|
|
654
|
+
* The shipped templates persist `filterData: { metricName,
|
|
655
|
+
* aggegationType }` and no attributes at all — the filter on
|
|
656
|
+
* `resource.k8s.cluster.name` is injected at render time by
|
|
657
|
+
* DashboardVariableInterpolation once the viewer picks a cluster. An
|
|
658
|
+
* allowlist built only from stored filters would reject every real
|
|
659
|
+
* templated dashboard the moment its variable is used.
|
|
660
|
+
*/
|
|
661
|
+
setWidgets(
|
|
662
|
+
[{ metricName: CHARTED_METRIC_NAME }],
|
|
663
|
+
["resource.k8s.cluster.name"],
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
await callWithAggregate({
|
|
667
|
+
query: {
|
|
668
|
+
name: CHARTED_METRIC_NAME,
|
|
669
|
+
attributes: { "resource.k8s.cluster.name": "prod-eu" },
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
674
|
+
|
|
675
|
+
const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
|
|
676
|
+
|
|
677
|
+
expect(
|
|
678
|
+
(query["attributes"] as JSONObject)["resource.k8s.cluster.name"],
|
|
679
|
+
).toBe("prod-eu");
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("forwards the widget-level attributeFilters a table widget persists", async () => {
|
|
683
|
+
setWidgets([
|
|
684
|
+
{
|
|
685
|
+
metricName: CHARTED_METRIC_NAME,
|
|
686
|
+
attributeFilters: { environment: "production" },
|
|
687
|
+
},
|
|
688
|
+
]);
|
|
689
|
+
|
|
690
|
+
await callWithAggregate({
|
|
691
|
+
query: {
|
|
692
|
+
name: CHARTED_METRIC_NAME,
|
|
693
|
+
attributes: { environment: "production" },
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
698
|
+
expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("drops every other client-supplied query key", async () => {
|
|
702
|
+
await callWithAggregate({
|
|
703
|
+
query: {
|
|
704
|
+
name: CHARTED_METRIC_NAME,
|
|
705
|
+
serviceId: ObjectID.generate().toString(),
|
|
706
|
+
primaryEntityId: "smuggled",
|
|
707
|
+
retentionDate: "2020-01-01",
|
|
708
|
+
value: { _type: "GreaterThan", value: 0 },
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
713
|
+
|
|
714
|
+
const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
|
|
715
|
+
|
|
716
|
+
expect(Object.keys(query).sort()).toEqual(["name", "projectId"]);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("keeps the time window the widget asks for", async () => {
|
|
720
|
+
await callWithAggregate({
|
|
721
|
+
query: {
|
|
722
|
+
name: CHARTED_METRIC_NAME,
|
|
723
|
+
time: {
|
|
724
|
+
_type: "InBetween",
|
|
725
|
+
value: {
|
|
726
|
+
startValue: "2026-01-01T00:00:00.000Z",
|
|
727
|
+
endValue: "2026-01-02T00:00:00.000Z",
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
734
|
+
|
|
735
|
+
const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
|
|
736
|
+
|
|
737
|
+
expect(query["time"]).toBeDefined();
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("pins the metric name as an own property of the query it aggregates", async () => {
|
|
741
|
+
await callWithAggregate();
|
|
742
|
+
|
|
743
|
+
const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
|
|
744
|
+
|
|
745
|
+
expect(Object.prototype.hasOwnProperty.call(query, "name")).toBe(true);
|
|
746
|
+
expect(query["name"]).toBe(CHARTED_METRIC_NAME);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("refuses a query that is not an object", async () => {
|
|
750
|
+
const badQueries: Array<unknown> = [null, "name", ["name"], 42, true];
|
|
751
|
+
|
|
752
|
+
for (const badQuery of badQueries) {
|
|
753
|
+
jest.clearAllMocks();
|
|
754
|
+
|
|
755
|
+
await callWithAggregate({ query: badQuery as JSONObject });
|
|
756
|
+
|
|
757
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
758
|
+
expectNothingAggregated();
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
/*
|
|
764
|
+
* JSON.parse turns a literal "__proto__" member into a real own key, and
|
|
765
|
+
* JSONFunctions.deserialize copies it with `newVal[key] = ...`, which fires
|
|
766
|
+
* Object.prototype's __proto__ setter. Every one of these bodies must be
|
|
767
|
+
* built with JSON.parse — a JS object literal sets the prototype at parse
|
|
768
|
+
* time and never produces the own key that makes the attack work.
|
|
769
|
+
*/
|
|
770
|
+
describe("prototype-carried keys", () => {
|
|
771
|
+
type ProtoBodyFunction = (json: string) => JSONObject;
|
|
772
|
+
|
|
773
|
+
const protoBody: ProtoBodyFunction = (json: string): JSONObject => {
|
|
774
|
+
return JSON.parse(json) as JSONObject;
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
it("refuses a metric name inherited through __proto__", async () => {
|
|
778
|
+
/*
|
|
779
|
+
* The allowlist check used to read `query["name"]` (which walks the
|
|
780
|
+
* prototype chain and saw the allowlisted name) while the project pin
|
|
781
|
+
* spread only own properties (which dropped it) — so the aggregation
|
|
782
|
+
* reached ClickHouse with NO name predicate at all: every metric in
|
|
783
|
+
* the project, from one anonymous request.
|
|
784
|
+
*/
|
|
785
|
+
await callRoute(
|
|
786
|
+
protoBody(
|
|
787
|
+
`{"aggregateBy":{"query":{"__proto__":{"name":"${CHARTED_METRIC_NAME}"}},` +
|
|
788
|
+
`"aggregateColumnName":"value","aggregationType":"Avg",` +
|
|
789
|
+
`"aggregationTimestampColumnName":"time",` +
|
|
790
|
+
`"startTimestamp":"2026-01-01T00:00:00.000Z",` +
|
|
791
|
+
`"endTimestamp":"2026-01-02T00:00:00.000Z","limit":100,"skip":0}}`,
|
|
792
|
+
),
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
const error: unknown = getThrownError();
|
|
796
|
+
|
|
797
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
798
|
+
expect((error as BadDataException).message).toBe(
|
|
799
|
+
"This metric is not part of this dashboard.",
|
|
800
|
+
);
|
|
801
|
+
expectNothingAggregated();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("refuses an attributes filter smuggled through __proto__", async () => {
|
|
805
|
+
await callRoute(
|
|
806
|
+
protoBody(
|
|
807
|
+
`{"aggregateBy":{"query":{"name":"${CHARTED_METRIC_NAME}",` +
|
|
808
|
+
`"attributes":{"__proto__":{"user.email":"a"}}},` +
|
|
809
|
+
`"aggregateColumnName":"value","aggregationType":"Avg",` +
|
|
810
|
+
`"aggregationTimestampColumnName":"time",` +
|
|
811
|
+
`"startTimestamp":"2026-01-01T00:00:00.000Z",` +
|
|
812
|
+
`"endTimestamp":"2026-01-02T00:00:00.000Z","limit":100,"skip":0}}`,
|
|
813
|
+
),
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
817
|
+
|
|
818
|
+
const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
|
|
819
|
+
const attributes: unknown = query["attributes"];
|
|
820
|
+
|
|
821
|
+
// Nothing inherited may survive into the object handed to the service.
|
|
822
|
+
for (const key in (attributes || {}) as Record<string, unknown>) {
|
|
823
|
+
expect(key).not.toBe("user.email");
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("does not let a group-by column hide on the prototype", async () => {
|
|
828
|
+
setWidgets([
|
|
829
|
+
{
|
|
830
|
+
metricName: CHARTED_METRIC_NAME,
|
|
831
|
+
groupBy: { serviceEntityKey: true },
|
|
832
|
+
},
|
|
833
|
+
]);
|
|
834
|
+
|
|
835
|
+
await callRoute(
|
|
836
|
+
protoBody(
|
|
837
|
+
`{"aggregateBy":{"query":{"name":"${CHARTED_METRIC_NAME}"},` +
|
|
838
|
+
`"groupBy":{"serviceEntityKey":true,"__proto__":{"attributes":true}},` +
|
|
839
|
+
`"aggregateColumnName":"value","aggregationType":"Avg",` +
|
|
840
|
+
`"aggregationTimestampColumnName":"time",` +
|
|
841
|
+
`"startTimestamp":"2026-01-01T00:00:00.000Z",` +
|
|
842
|
+
`"endTimestamp":"2026-01-02T00:00:00.000Z","limit":100,"skip":0}}`,
|
|
843
|
+
),
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
847
|
+
|
|
848
|
+
/*
|
|
849
|
+
* The guard uses Object.keys but the SQL builder walks groupBy with
|
|
850
|
+
* for...in, so an inherited key would reach GROUP BY unvalidated.
|
|
851
|
+
* Assert the two agree on the object actually handed to the service.
|
|
852
|
+
*/
|
|
853
|
+
const groupBy: Record<string, unknown> = getAggregateArgs()[
|
|
854
|
+
"groupBy"
|
|
855
|
+
] as Record<string, unknown>;
|
|
856
|
+
const enumerated: Array<string> = [];
|
|
857
|
+
|
|
858
|
+
for (const key in groupBy) {
|
|
859
|
+
enumerated.push(key);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
expect(enumerated).toEqual(["serviceEntityKey"]);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it("does not treat a prototype key as a configured grouping", async () => {
|
|
866
|
+
await callWithAggregate({
|
|
867
|
+
groupByAttributeKeys: ["__proto__", "constructor", "toString"],
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
871
|
+
expectNothingAggregated();
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
describe("aggregate column and pagination", () => {
|
|
876
|
+
/*
|
|
877
|
+
* `aggregateColumnName` was forwarded verbatim. A non-`value` column
|
|
878
|
+
* skips every MetricService fast path and falls through to
|
|
879
|
+
* `max(<column>) as <column>`; the row mapper only parseFloats strings
|
|
880
|
+
* and copies anything else into `value` untouched — so asking to
|
|
881
|
+
* aggregate `attributes` returned a whole attribute map per time bucket.
|
|
882
|
+
*/
|
|
883
|
+
it("refuses an aggregateColumnName the widgets never aggregate", async () => {
|
|
884
|
+
const columns: Array<string> = [
|
|
885
|
+
"attributes",
|
|
886
|
+
"name",
|
|
887
|
+
"serviceEntityKey",
|
|
888
|
+
"projectId",
|
|
889
|
+
"time",
|
|
890
|
+
];
|
|
891
|
+
|
|
892
|
+
for (const column of columns) {
|
|
893
|
+
jest.clearAllMocks();
|
|
894
|
+
|
|
895
|
+
await callWithAggregate({ aggregateColumnName: column });
|
|
896
|
+
|
|
897
|
+
const error: unknown = getThrownError();
|
|
898
|
+
|
|
899
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
900
|
+
expect((error as BadDataException).message).toBe(
|
|
901
|
+
"Invalid aggregateColumnName.",
|
|
902
|
+
);
|
|
903
|
+
expectNothingAggregated();
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("refuses an aggregationTimestampColumnName other than time", async () => {
|
|
908
|
+
for (const column of ["createdAt", "attributes", "value"]) {
|
|
909
|
+
jest.clearAllMocks();
|
|
910
|
+
|
|
911
|
+
await callWithAggregate({ aggregationTimestampColumnName: column });
|
|
912
|
+
|
|
913
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
914
|
+
expectNothingAggregated();
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("accepts the value/time pair every widget actually sends", async () => {
|
|
919
|
+
await callWithAggregate({
|
|
920
|
+
aggregateColumnName: "value",
|
|
921
|
+
aggregationTimestampColumnName: "time",
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
925
|
+
expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("clamps an oversized limit and a negative skip", async () => {
|
|
929
|
+
await callWithAggregate({ limit: 10000000, skip: -5 });
|
|
930
|
+
|
|
931
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
932
|
+
|
|
933
|
+
const args: JSONObject = getAggregateArgs();
|
|
934
|
+
|
|
935
|
+
expect(args["limit"]).toBe(LIMIT_PER_PROJECT);
|
|
936
|
+
expect(args["skip"]).toBe(0);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it("falls back to a sane limit for a nonsensical one", async () => {
|
|
940
|
+
for (const limit of [0, -1, "not-a-number", null]) {
|
|
941
|
+
jest.clearAllMocks();
|
|
942
|
+
|
|
943
|
+
await callWithAggregate({ limit: limit as number });
|
|
944
|
+
|
|
945
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
946
|
+
|
|
947
|
+
const limitSent: unknown = getAggregateArgs()["limit"];
|
|
948
|
+
|
|
949
|
+
expect(typeof limitSent).toBe("number");
|
|
950
|
+
expect(limitSent as number).toBeGreaterThan(0);
|
|
951
|
+
expect(limitSent as number).toBeLessThanOrEqual(LIMIT_PER_PROJECT);
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it("honours a sensible limit and skip", async () => {
|
|
956
|
+
await callWithAggregate({ limit: 25, skip: 50 });
|
|
957
|
+
|
|
958
|
+
const args: JSONObject = getAggregateArgs();
|
|
959
|
+
|
|
960
|
+
expect(args["limit"]).toBe(25);
|
|
961
|
+
expect(args["skip"]).toBe(50);
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
describe("null handling", () => {
|
|
966
|
+
/*
|
|
967
|
+
* JSONFunctions.serialize drops `undefined` but preserves `null`, and
|
|
968
|
+
* both downstream sinks already treat a null groupBy as absent — so the
|
|
969
|
+
* route must too, or an ungrouped widget round-tripped through the wire
|
|
970
|
+
* would 400.
|
|
971
|
+
*/
|
|
972
|
+
it("treats an explicit null groupBy and groupByAttributeKeys as absent", async () => {
|
|
973
|
+
await callWithAggregate({
|
|
974
|
+
groupBy: null as unknown as JSONObject,
|
|
975
|
+
groupByAttributeKeys: null as unknown as Array<string>,
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
979
|
+
expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
describe("authorization", () => {
|
|
984
|
+
it("rejects a dashboard that is not public before aggregating anything", async () => {
|
|
985
|
+
dashboard.isPublicDashboard = false;
|
|
986
|
+
|
|
987
|
+
await callWithAggregate();
|
|
988
|
+
|
|
989
|
+
expect(getThrownError()).toBeInstanceOf(NotAuthenticatedException);
|
|
990
|
+
expectNothingAggregated();
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it("rejects a dashboard that cannot be found", async () => {
|
|
994
|
+
jest.spyOn(DashboardService, "findOneById").mockResolvedValue(null);
|
|
995
|
+
|
|
996
|
+
await callWithAggregate();
|
|
997
|
+
|
|
998
|
+
expect(getThrownError()).toBeInstanceOf(NotAuthenticatedException);
|
|
999
|
+
expectNothingAggregated();
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("rejects a dashboard with no project", async () => {
|
|
1003
|
+
delete dashboard.projectId;
|
|
1004
|
+
|
|
1005
|
+
await callWithAggregate();
|
|
1006
|
+
|
|
1007
|
+
expect(getThrownError()).toBeInstanceOf(NotFoundException);
|
|
1008
|
+
expectNothingAggregated();
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
describe("stored config shapes", () => {
|
|
1013
|
+
it("collects group-by keys from every shape the widgets persist", async () => {
|
|
1014
|
+
const shapes: Array<{ label: string; config: JSONObject }> = [
|
|
1015
|
+
{
|
|
1016
|
+
label: "table widget groupByAttributes",
|
|
1017
|
+
config: {
|
|
1018
|
+
arguments: {
|
|
1019
|
+
groupByAttributes: [{ key: "shape.key" }],
|
|
1020
|
+
metricQueryConfigs: [
|
|
1021
|
+
{ metricQueryData: { filterData: { metricName: "m" } } },
|
|
1022
|
+
],
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
label: "table widget groupByAttributeKeys",
|
|
1028
|
+
config: {
|
|
1029
|
+
arguments: {
|
|
1030
|
+
groupByAttributeKeys: ["shape.key"],
|
|
1031
|
+
metricQueryConfigs: [
|
|
1032
|
+
{ metricQueryData: { filterData: { metricName: "m" } } },
|
|
1033
|
+
],
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
label: "legacy singular metricQueryConfig",
|
|
1039
|
+
config: {
|
|
1040
|
+
arguments: {
|
|
1041
|
+
metricQueryConfig: {
|
|
1042
|
+
metricQueryData: {
|
|
1043
|
+
filterData: { metricName: "m" },
|
|
1044
|
+
groupByAttributeKeys: ["shape.key"],
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
{
|
|
1051
|
+
label: "plural metricQueryConfigs",
|
|
1052
|
+
config: {
|
|
1053
|
+
arguments: {
|
|
1054
|
+
metricQueryConfigs: [
|
|
1055
|
+
{
|
|
1056
|
+
metricQueryData: {
|
|
1057
|
+
filterData: { metricName: "m" },
|
|
1058
|
+
groupByAttributeKeys: ["shape.key"],
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
],
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
},
|
|
1065
|
+
];
|
|
1066
|
+
|
|
1067
|
+
for (const shape of shapes) {
|
|
1068
|
+
jest.clearAllMocks();
|
|
1069
|
+
|
|
1070
|
+
dashboard.dashboardViewConfig = {
|
|
1071
|
+
_type: "DashboardViewConfig",
|
|
1072
|
+
heightInDashboardUnits: 24,
|
|
1073
|
+
components: [
|
|
1074
|
+
{
|
|
1075
|
+
componentType: DashboardComponentType.Chart,
|
|
1076
|
+
...shape.config,
|
|
1077
|
+
},
|
|
1078
|
+
],
|
|
1079
|
+
} as unknown as DashboardViewConfig;
|
|
1080
|
+
|
|
1081
|
+
await callWithAggregate({
|
|
1082
|
+
query: { name: "m" },
|
|
1083
|
+
groupByAttributeKeys: ["shape.key"],
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
1087
|
+
expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it("survives malformed groupByAttributes without widening the allowlist", async () => {
|
|
1092
|
+
const malformed: Array<unknown> = [
|
|
1093
|
+
null,
|
|
1094
|
+
7,
|
|
1095
|
+
"shape.key",
|
|
1096
|
+
{},
|
|
1097
|
+
{ key: 42 },
|
|
1098
|
+
{ key: "" },
|
|
1099
|
+
[],
|
|
1100
|
+
];
|
|
1101
|
+
|
|
1102
|
+
for (const entry of malformed) {
|
|
1103
|
+
jest.clearAllMocks();
|
|
1104
|
+
|
|
1105
|
+
setWidgets([
|
|
1106
|
+
{
|
|
1107
|
+
metricName: CHARTED_METRIC_NAME,
|
|
1108
|
+
groupByAttributes: [entry as JSONObject],
|
|
1109
|
+
},
|
|
1110
|
+
]);
|
|
1111
|
+
|
|
1112
|
+
await callWithAggregate({ groupByAttributeKeys: ["shape.key"] });
|
|
1113
|
+
|
|
1114
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
1115
|
+
expectNothingAggregated();
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it("allows whole-attribute-map grouping when the widget itself is configured that way", async () => {
|
|
1120
|
+
// Legacy stored configs still carry groupBy: { attributes: true }.
|
|
1121
|
+
setWidgets([
|
|
1122
|
+
{
|
|
1123
|
+
metricName: CHARTED_METRIC_NAME,
|
|
1124
|
+
groupBy: { attributes: true },
|
|
1125
|
+
},
|
|
1126
|
+
]);
|
|
1127
|
+
|
|
1128
|
+
await callWithAggregate({ groupBy: { attributes: true } });
|
|
1129
|
+
|
|
1130
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
1131
|
+
expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
it("accepts an aggregateBy that has been through the wire serializer", async () => {
|
|
1135
|
+
/*
|
|
1136
|
+
* The client serializes AggregateBy before POSTing it, so operators
|
|
1137
|
+
* and dates arrive as `_type`-tagged objects and are revived by
|
|
1138
|
+
* JSONFunctions.deserialize inside the handler. Round-trip a realistic
|
|
1139
|
+
* payload through serialize -> JSON -> the route.
|
|
1140
|
+
*/
|
|
1141
|
+
setWidgets([
|
|
1142
|
+
{
|
|
1143
|
+
metricName: CHARTED_METRIC_NAME,
|
|
1144
|
+
attributes: { "host.name": "web-1" },
|
|
1145
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
|
|
1146
|
+
},
|
|
1147
|
+
]);
|
|
1148
|
+
|
|
1149
|
+
const startDate: Date = new Date("2026-01-01T00:00:00.000Z");
|
|
1150
|
+
const endDate: Date = new Date("2026-01-02T00:00:00.000Z");
|
|
1151
|
+
|
|
1152
|
+
const wirePayload: JSONObject = JSON.parse(
|
|
1153
|
+
JSON.stringify(
|
|
1154
|
+
JSONFunctions.serialize({
|
|
1155
|
+
query: {
|
|
1156
|
+
projectId: ObjectID.generate(),
|
|
1157
|
+
name: CHARTED_METRIC_NAME,
|
|
1158
|
+
time: new InBetween(startDate, endDate),
|
|
1159
|
+
attributes: { "host.name": new Includes(["web-1", "web-2"]) },
|
|
1160
|
+
},
|
|
1161
|
+
aggregationType: "Avg",
|
|
1162
|
+
aggregateColumnName: "value",
|
|
1163
|
+
aggregationTimestampColumnName: "time",
|
|
1164
|
+
startTimestamp: startDate,
|
|
1165
|
+
endTimestamp: endDate,
|
|
1166
|
+
limit: LIMIT_PER_PROJECT,
|
|
1167
|
+
skip: 0,
|
|
1168
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
|
|
1169
|
+
topK: { count: 10, rankBy: "max" },
|
|
1170
|
+
} as unknown as JSONObject),
|
|
1171
|
+
),
|
|
1172
|
+
) as JSONObject;
|
|
1173
|
+
|
|
1174
|
+
await callRoute({ aggregateBy: wirePayload });
|
|
1175
|
+
|
|
1176
|
+
expect(nextFunction).not.toHaveBeenCalled();
|
|
1177
|
+
|
|
1178
|
+
const args: JSONObject = getAggregateArgs();
|
|
1179
|
+
const query: JSONObject = args["query"] as JSONObject;
|
|
1180
|
+
|
|
1181
|
+
expect(args["startTimestamp"]).toBeInstanceOf(Date);
|
|
1182
|
+
expect(args["groupByAttributeKeys"]).toEqual([CHARTED_GROUP_BY_KEY]);
|
|
1183
|
+
expect(args["topK"]).toEqual({ count: 10, rankBy: "max" });
|
|
1184
|
+
expect(query["name"]).toBe(CHARTED_METRIC_NAME);
|
|
1185
|
+
expect(query["projectId"]).toBe(projectId);
|
|
1186
|
+
expect((query["attributes"] as JSONObject)["host.name"]).toBeInstanceOf(
|
|
1187
|
+
Includes,
|
|
1188
|
+
);
|
|
1189
|
+
expect(query["time"]).toBeInstanceOf(InBetween);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it("refuses an aggregateBy that is not an object", async () => {
|
|
1193
|
+
const badPayloads: Array<unknown> = [0, "", "aggregate", [], true, 42];
|
|
1194
|
+
|
|
1195
|
+
for (const payload of badPayloads) {
|
|
1196
|
+
jest.clearAllMocks();
|
|
1197
|
+
|
|
1198
|
+
await callRoute({ aggregateBy: payload as JSONObject });
|
|
1199
|
+
|
|
1200
|
+
expect(nextFunction).toHaveBeenCalled();
|
|
1201
|
+
expectNothingAggregated();
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
describe("normalization", () => {
|
|
1207
|
+
it("does not trim the requested metric name", async () => {
|
|
1208
|
+
await callWithAggregate({
|
|
1209
|
+
query: { name: ` ${CHARTED_METRIC_NAME} ` },
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
1213
|
+
expectNothingAggregated();
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
it("does not trim a requested group-by key", async () => {
|
|
1217
|
+
setWidgets([
|
|
1218
|
+
{
|
|
1219
|
+
metricName: CHARTED_METRIC_NAME,
|
|
1220
|
+
groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
|
|
1221
|
+
},
|
|
1222
|
+
]);
|
|
1223
|
+
|
|
1224
|
+
await callWithAggregate({
|
|
1225
|
+
groupByAttributeKeys: [` ${CHARTED_GROUP_BY_KEY} `],
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
1229
|
+
expectNothingAggregated();
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
it("matches metric names case-sensitively", async () => {
|
|
1233
|
+
await callWithAggregate({
|
|
1234
|
+
query: { name: CHARTED_METRIC_NAME.toUpperCase() },
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
expect(getThrownError()).toBeInstanceOf(BadDataException);
|
|
1238
|
+
expectNothingAggregated();
|
|
1239
|
+
});
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
describe("response", () => {
|
|
1243
|
+
it("returns the aggregated rows unchanged", async () => {
|
|
1244
|
+
const aggregated: JSONObject = {
|
|
1245
|
+
data: [{ time: "2026-01-01T00:00:00.000Z", value: 1 }],
|
|
1246
|
+
totalGroups: 7,
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
jest
|
|
1250
|
+
.spyOn(MetricService, "aggregateBy")
|
|
1251
|
+
.mockResolvedValue(aggregated as never);
|
|
1252
|
+
|
|
1253
|
+
await callWithAggregate();
|
|
1254
|
+
|
|
1255
|
+
expect(Response.sendJsonObjectResponse).toHaveBeenCalledTimes(1);
|
|
1256
|
+
|
|
1257
|
+
const responseCall: Array<unknown> = (
|
|
1258
|
+
Response.sendJsonObjectResponse as jest.Mock
|
|
1259
|
+
).mock.calls[0] as Array<unknown>;
|
|
1260
|
+
|
|
1261
|
+
expect(responseCall[2]).toEqual(aggregated);
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it("sends no response at all when the request is refused", async () => {
|
|
1265
|
+
await callWithAggregate({ query: { name: "billing.revenue.total" } });
|
|
1266
|
+
|
|
1267
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
describe("project scoping", () => {
|
|
1272
|
+
it("pins the aggregation to the dashboard's own project", async () => {
|
|
1273
|
+
await callWithAggregate();
|
|
1274
|
+
|
|
1275
|
+
const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
|
|
1276
|
+
|
|
1277
|
+
expect(query["projectId"]).toBe(projectId);
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
it("ignores a client-supplied projectId pointing at another tenant", async () => {
|
|
1281
|
+
const otherProjectId: ObjectID = ObjectID.generate();
|
|
1282
|
+
|
|
1283
|
+
await callWithAggregate({
|
|
1284
|
+
query: {
|
|
1285
|
+
name: CHARTED_METRIC_NAME,
|
|
1286
|
+
projectId: otherProjectId.toString(),
|
|
1287
|
+
},
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
|
|
1291
|
+
|
|
1292
|
+
expect(query["projectId"]).toBe(projectId);
|
|
1293
|
+
expect(query["projectId"]).not.toBe(otherProjectId.toString());
|
|
1294
|
+
});
|
|
1295
|
+
});
|
|
1296
|
+
});
|