@oneuptime/common 14.0.8 → 14.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/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/KubernetesAiAgent.ts +364 -0
- package/Models/DatabaseModels/KubernetesCluster.ts +3 -3
- package/Models/DatabaseModels/Project.ts +55 -5
- package/Models/DatabaseModels/ProxmoxResource.ts +49 -1
- package/Models/DatabaseModels/RunnerJob.ts +75 -0
- package/Models/DatabaseModels/TeamComplianceSetting.ts +143 -2
- package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +17 -0
- package/Server/API/AIChatAPI.ts +6 -3
- package/Server/API/AIInvestigationAPI.ts +17 -10
- package/Server/API/AutoRemediationAPI.ts +207 -25
- package/Server/API/KubernetesClusterAiAccessAPI.ts +623 -21
- package/Server/API/MicrosoftTeamsAPI.ts +93 -14
- package/Server/API/ProxmoxResourceAPI.ts +244 -14
- package/Server/Infrastructure/GlobalCache.ts +32 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.ts +99 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.ts +137 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Middleware/TelemetryIngest.ts +41 -18
- package/Server/Services/AIService.ts +70 -1
- package/Server/Services/AlertService.ts +352 -11
- package/Server/Services/AlertSeverityService.ts +65 -15
- package/Server/Services/AutoRemediationRuleService.ts +1 -1
- package/Server/Services/IncidentService.ts +280 -104
- package/Server/Services/IncidentSeverityService.ts +66 -15
- package/Server/Services/KubernetesAiAgentJobService.ts +185 -0
- package/Server/Services/KubernetesAiAgentService.ts +1634 -0
- package/Server/Services/KubernetesClusterAiAccessService.ts +1118 -451
- package/Server/Services/KubernetesClusterService.ts +129 -21
- package/Server/Services/ProjectService.ts +32 -0
- package/Server/Services/ProxmoxResourceService.ts +322 -5
- package/Server/Services/RunnerJobService.ts +241 -104
- package/Server/Services/RunnerService.ts +1 -1
- package/Server/Services/ScheduledMaintenanceService.ts +737 -1
- package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +414 -56
- package/Server/Services/TeamComplianceSettingService.ts +1125 -9
- package/Server/Utils/AI/ClusterAccess/ClusterAccessContext.ts +67 -9
- package/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.ts +39 -10
- package/Server/Utils/AI/ClusterAccess/KubectlJobRunner.ts +14 -3
- package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +37 -15
- package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +67 -24
- package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +48 -5
- package/Server/Utils/AI/SRE/IncidentPostmortemRunner.ts +40 -8
- package/Server/Utils/AI/SRE/InvestigationEligibility.ts +13 -2
- package/Server/Utils/AI/SRE/InvestigationGrader.ts +1 -1
- package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +6 -1
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +133 -7
- package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +58 -4
- package/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.ts +447 -0
- package/Server/Utils/Telemetry/ProxmoxNativePush.ts +903 -0
- package/Server/Utils/Telemetry/TelemetryEntity.ts +87 -27
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +684 -47
- package/Tests/App/AdminDashboard/RowActionPlacementOverrides.test.tsx +278 -0
- package/Tests/App/Dashboard/AIChatChartWidgetZoom.test.tsx +738 -0
- package/Tests/App/Dashboard/AIFeaturesSettingsPointers.test.tsx +147 -0
- package/Tests/App/Dashboard/AIInsightsKubernetesClusterPage.test.tsx +1523 -0
- package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +463 -3
- package/Tests/App/Dashboard/AffectedResourcesPickerDropdownPlacement.test.tsx +958 -0
- package/Tests/App/Dashboard/AffectedResourcesPickerFocusLeave.test.tsx +595 -0
- package/Tests/App/Dashboard/AskAiAvailability.test.ts +20 -3
- package/Tests/App/Dashboard/AskAiDisabled.test.tsx +8 -3
- package/Tests/App/Dashboard/CephRateChartReload.test.tsx +1456 -0
- package/Tests/App/Dashboard/CephTimeRangeZoom.test.tsx +1229 -0
- package/Tests/App/Dashboard/ChartCardTimeRangeZoom.test.tsx +151 -0
- package/Tests/App/Dashboard/ChartCardZoomAffordances.test.tsx +257 -0
- package/Tests/App/Dashboard/ChartRefetchFrame.test.tsx +141 -0
- package/Tests/App/Dashboard/ChartZoomHarness.tsx +249 -0
- package/Tests/App/Dashboard/ChartZoomStandIn.tsx +131 -0
- package/Tests/App/Dashboard/CloudServerlessSlowLoads.test.tsx +628 -0
- package/Tests/App/Dashboard/CloudServerlessTimeRangeZoom.test.tsx +607 -0
- package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +91 -19
- package/Tests/App/Dashboard/CompanionMetricsTabZoom.test.tsx +632 -0
- package/Tests/App/Dashboard/ContainerChartZoomStandIn.tsx +195 -0
- package/Tests/App/Dashboard/ContainerHostOverviewSlowLoads.test.tsx +542 -0
- package/Tests/App/Dashboard/ContainerHostOverviewTimeRangeZoom.test.tsx +848 -0
- package/Tests/App/Dashboard/ContainerMetricsTimeRangeZoom.test.tsx +561 -0
- package/Tests/App/Dashboard/DashboardBoardWideZoomShell.test.tsx +1147 -0
- package/Tests/App/Dashboard/DashboardEditPermissions.test.tsx +3 -2
- package/Tests/App/Dashboard/DashboardHistogramWidgetDragCursor.test.tsx +399 -0
- package/Tests/App/Dashboard/DashboardHistogramWidgetZoom.test.tsx +985 -0
- package/Tests/App/Dashboard/DashboardHistogramWidgetZoomHint.test.tsx +803 -0
- package/Tests/App/Dashboard/DashboardLazyPages.test.tsx +1 -0
- package/Tests/App/Dashboard/DashboardSettingsPreviewZoom.test.tsx +679 -0
- package/Tests/App/Dashboard/DashboardSloAndDataSourceChartZoom.test.tsx +881 -0
- package/Tests/App/Dashboard/DashboardTraceStatusWidgets.test.tsx +878 -0
- package/Tests/App/Dashboard/DatabaseMetricChartModalTimeRangeZoom.test.tsx +442 -0
- package/Tests/App/Dashboard/DatabaseOverviewTimeRangeZoom.test.tsx +831 -0
- package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +43 -0
- package/Tests/App/Dashboard/DockerSwarmInsightsTimeRangeZoom.test.tsx +524 -0
- package/Tests/App/Dashboard/DockerSwarmRateChartRefresh.test.tsx +259 -0
- package/Tests/App/Dashboard/EdgeDetailPanelHistory.test.tsx +763 -0
- package/Tests/App/Dashboard/EdgeDetailPanelTimeRangeZoom.test.tsx +547 -0
- package/Tests/App/Dashboard/EdgeHistoryBucketsCases.tsx +565 -0
- package/Tests/App/Dashboard/EdgeHistoryBucketsKathmandu.test.tsx +73 -0
- package/Tests/App/Dashboard/EdgeHistoryBucketsKolkata.test.tsx +74 -0
- package/Tests/App/Dashboard/EdgeHistoryBucketsNewYork.test.tsx +72 -0
- package/Tests/App/Dashboard/EdgeHistoryBucketsUtc.test.tsx +72 -0
- package/Tests/App/Dashboard/EmbeddedMetricCardRefresh.test.tsx +310 -0
- package/Tests/App/Dashboard/EmbeddedMetricCardTimeRangeZoom.test.tsx +536 -0
- package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +3 -1
- package/Tests/App/Dashboard/EscalationRuleRowActions.test.tsx +660 -0
- package/Tests/App/Dashboard/EventNotes.test.tsx +2 -2
- package/Tests/App/Dashboard/EventOverviewPages.test.tsx +587 -13
- package/Tests/App/Dashboard/EventOverviewSnapshotExplorerZoom.test.tsx +1139 -0
- package/Tests/App/Dashboard/EventOverviewSnapshotZoomAcrossTabs.test.tsx +1057 -0
- package/Tests/App/Dashboard/EventOverviewTelemetrySnapshotZoom.test.tsx +757 -0
- package/Tests/App/Dashboard/ExceptionOccurrenceTrendZoom.test.tsx +675 -0
- package/Tests/App/Dashboard/ExceptionOccurrencesZoomSurvivesViewSwitch.test.tsx +405 -0
- package/Tests/App/Dashboard/ExceptionSignalPages.test.tsx +67 -3
- package/Tests/App/Dashboard/ExceptionTablesSpanStatus.test.tsx +515 -0
- package/Tests/App/Dashboard/ExplorerOwnZoomRegression.test.tsx +739 -0
- package/Tests/App/Dashboard/FlowBandwidthAxisCases.tsx +461 -0
- package/Tests/App/Dashboard/FlowBandwidthAxisKathmandu.test.tsx +25 -0
- package/Tests/App/Dashboard/FlowBandwidthAxisKolkata.test.tsx +25 -0
- package/Tests/App/Dashboard/FlowBandwidthAxisNewYork.test.tsx +25 -0
- package/Tests/App/Dashboard/FlowBandwidthAxisUtc.test.tsx +24 -0
- package/Tests/App/Dashboard/FlowTopTalkersZoomRefetch.test.tsx +722 -0
- package/Tests/App/Dashboard/FlowViewportGuard.test.tsx +2526 -0
- package/Tests/App/Dashboard/HostPagesSlowLoads.test.tsx +872 -0
- package/Tests/App/Dashboard/HostPagesTimeRangeZoom.test.tsx +1208 -0
- package/Tests/App/Dashboard/IncidentRootCauseTimeRangeZoom.test.tsx +358 -0
- package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +100 -9
- package/Tests/App/Dashboard/InfrastructureMetricsTabTimeRangeZoom.test.tsx +485 -0
- package/Tests/App/Dashboard/InvestigationDrawerCompanionZoom.test.tsx +655 -0
- package/Tests/App/Dashboard/InvestigationDrawerZoom.test.tsx +830 -0
- package/Tests/App/Dashboard/InvestigationNotStarted.test.tsx +338 -2
- package/Tests/App/Dashboard/InvoicesPayInvoice.test.tsx +454 -16
- package/Tests/App/Dashboard/KubernetesClusterAiAccessSettings.test.ts +799 -819
- package/Tests/App/Dashboard/KubernetesClusterAiAgentOverviewCard.test.tsx +367 -0
- package/Tests/App/Dashboard/KubernetesClusterAiAgentStatus.test.ts +1202 -0
- package/Tests/App/Dashboard/KubernetesClusterAiDocumentationMarkdown.test.ts +106 -0
- package/Tests/App/Dashboard/KubernetesClusterAiNavigation.test.tsx +354 -0
- package/Tests/App/Dashboard/KubernetesClusterAiPage.test.tsx +1884 -2054
- package/Tests/App/Dashboard/KubernetesClusterAiPageServerParity.test.ts +478 -179
- package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +50 -0
- package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +2 -1
- package/Tests/App/Dashboard/KubernetesClusterOverviewZoom.test.tsx +1217 -0
- package/Tests/App/Dashboard/KubernetesClusterPagesZoom.test.tsx +731 -0
- package/Tests/App/Dashboard/KubernetesCostsLoadRecovery.test.tsx +869 -0
- package/Tests/App/Dashboard/KubernetesCostsZoom.test.tsx +1214 -0
- package/Tests/App/Dashboard/KubernetesCostsZoomExactHours.test.tsx +692 -0
- package/Tests/App/Dashboard/KubernetesNetworkThroughputReload.test.tsx +902 -0
- package/Tests/App/Dashboard/KubernetesNetworkZoomBucketEdges.test.tsx +571 -0
- package/Tests/App/Dashboard/KubernetesOverviewZoomBucketEdges.test.tsx +697 -0
- package/Tests/App/Dashboard/KubernetesResourceMetricsTabZoom.test.tsx +633 -0
- package/Tests/App/Dashboard/LogsExplorerTimeRangeZoom.test.tsx +470 -0
- package/Tests/App/Dashboard/LogsInsightsTimeRangeZoom.test.tsx +948 -0
- package/Tests/App/Dashboard/LogsViewerPinnedWindowZoom.test.tsx +486 -0
- package/Tests/App/Dashboard/MetricChartsBucketInspectorDoubleClick.test.tsx +1001 -0
- package/Tests/App/Dashboard/MetricExplorerInvestigationAutoRefresh.test.tsx +514 -0
- package/Tests/App/Dashboard/MetricExplorerSavedViewZoomRoundTrip.test.tsx +581 -0
- package/Tests/App/Dashboard/MetricExplorerTimeRangeZoom.test.tsx +1162 -0
- package/Tests/App/Dashboard/MetricMonitorPreviewZoomLabel.test.ts +72 -0
- package/Tests/App/Dashboard/MetricRowSparklineTimeRangeZoom.test.tsx +204 -0
- package/Tests/App/Dashboard/MetricViewFirstResultLands.test.tsx +408 -0
- package/Tests/App/Dashboard/MetricViewOwnZoomResetPlacement.test.tsx +952 -0
- package/Tests/App/Dashboard/MetricViewTimeRange.test.ts +139 -0
- package/Tests/App/Dashboard/MetricViewTimeRangeZoom.test.tsx +556 -0
- package/Tests/App/Dashboard/MonitorMetricsChartWrappersZoom.test.tsx +552 -0
- package/Tests/App/Dashboard/MonitorMetricsTabsTimeRangeZoom.test.tsx +740 -0
- package/Tests/App/Dashboard/MonitorOverviewChartsTimeRangeZoom.test.tsx +735 -0
- package/Tests/App/Dashboard/MonitorStepPreviewsTimeRangeZoom.test.tsx +835 -0
- package/Tests/App/Dashboard/NetworkDeviceTimeRangeZoom.test.tsx +1115 -0
- package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +186 -17
- package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +155 -46
- package/Tests/App/Dashboard/PasskeySettings.test.tsx +87 -2
- package/Tests/App/Dashboard/ProjectAISettingsPages.test.tsx +884 -0
- package/Tests/App/Dashboard/ProxmoxNodeRemove.test.tsx +621 -0
- package/Tests/App/Dashboard/ProxmoxOverviewSlowLoads.test.tsx +584 -0
- package/Tests/App/Dashboard/ProxmoxPagesTimeRangeZoom.test.tsx +823 -0
- package/Tests/App/Dashboard/ProxmoxRateChartReload.test.tsx +1253 -0
- package/Tests/App/Dashboard/PublicDashboardBoardWideZoom.test.tsx +605 -0
- package/Tests/App/Dashboard/RealRowsLineChart.tsx +250 -0
- package/Tests/App/Dashboard/RechartsHostsPlotCursor.test.tsx +513 -0
- package/Tests/App/Dashboard/RowActionPlacementOverrides.test.tsx +865 -0
- package/Tests/App/Dashboard/RumOverviewTimeRangeZoom.test.tsx +877 -0
- package/Tests/App/Dashboard/RunnerKubernetesAgentForm.test.tsx +20 -4
- package/Tests/App/Dashboard/RunnerSupersededByAiAgentNote.test.tsx +296 -0
- package/Tests/App/Dashboard/SecurityEventConnectionsTable.test.tsx +146 -36
- package/Tests/App/Dashboard/ServiceDatabaseSlowLoads.test.tsx +1166 -0
- package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +1243 -104
- package/Tests/App/Dashboard/ServiceOverviewTimeRangeZoom.test.tsx +792 -0
- package/Tests/App/Dashboard/SloBudgetBurnDownZoom.test.tsx +771 -0
- package/Tests/App/Dashboard/SloBudgetBurnDownZoomAffordances.test.tsx +380 -0
- package/Tests/App/Dashboard/SloEventMetricsBarChartZoom.test.tsx +369 -0
- package/Tests/App/Dashboard/SloHistoryChartsTimeRangeZoom.test.tsx +694 -0
- package/Tests/App/Dashboard/SloHistoryChartsZoomAffordances.test.tsx +497 -0
- package/Tests/App/Dashboard/SloMetricsTimeRangeZoom.test.tsx +541 -0
- package/Tests/App/Dashboard/SlowLoadHarness.ts +123 -0
- package/Tests/App/Dashboard/SnapshotExplorerPrimaryZoom.test.tsx +1194 -0
- package/Tests/App/Dashboard/SnapshotViewerPinnedWindow.test.tsx +620 -0
- package/Tests/App/Dashboard/TelemetrySnapshotPanelTimeRangeZoom.test.tsx +765 -0
- package/Tests/App/Dashboard/TelemetrySnapshotZoom.test.tsx +516 -0
- package/Tests/App/Dashboard/TelemetrySnapshotZoomAcrossTabs.test.tsx +827 -0
- package/Tests/App/Dashboard/TimeRangeZoomPageHarness.tsx +463 -0
- package/Tests/App/Dashboard/TraceChartEditorStatusSplit.test.tsx +543 -0
- package/Tests/App/Dashboard/TraceStatusPresentation.test.tsx +967 -0
- package/Tests/App/Dashboard/TraceTimelineMinimapZoom.test.tsx +315 -0
- package/Tests/App/Dashboard/TracesAnalyticsStatusSplit.test.tsx +778 -0
- package/Tests/App/Dashboard/TracesAnalyticsViewZoom.test.tsx +349 -0
- package/Tests/App/Dashboard/TracesExplorerTimeRangeZoom.test.tsx +551 -0
- package/Tests/App/Dashboard/TracesStatusChartAndFacet.test.tsx +687 -0
- package/Tests/App/Dashboard/TracesViewerStaleResponses.test.tsx +458 -0
- package/Tests/App/Dashboard/UptimeStripsExcludedFromZoom.test.tsx +406 -0
- package/Tests/App/Dashboard/VMwareOverviewSlowLoads.test.tsx +535 -0
- package/Tests/App/Dashboard/VMwarePagesTimeRangeZoom.test.tsx +606 -0
- package/Tests/App/Dashboard/ValueWidgetSparklineStaleHover.test.tsx +1292 -0
- package/Tests/App/Dashboard/ValueWidgetSparklineZoom.test.tsx +765 -0
- package/Tests/App/StatusPage/StatusPageResourceExplorer.test.tsx +98 -0
- package/Tests/Models/DatabaseModels/KubernetesAiAgentModel.test.ts +295 -0
- package/Tests/Models/DatabaseModels/TeamComplianceSettingModel.test.ts +347 -0
- package/Tests/Server/API/AIChatRouteAuthorization.test.ts +58 -0
- package/Tests/Server/API/AIGenerationProjectAIToggle.test.ts +1 -1
- package/Tests/Server/API/AIInvestigationAPI.test.ts +123 -45
- package/Tests/Server/API/AutoRemediationApproveClusterRound.test.ts +1329 -0
- package/Tests/Server/API/AutoRemediationApproveKubectlRecheck.test.ts +25 -2
- package/Tests/Server/API/KubernetesClusterAiAccessAPI.test.ts +2 -0
- package/Tests/Server/API/KubernetesClusterAiAccessAPIAgent.test.ts +903 -0
- package/Tests/Server/API/MicrosoftTeamsChatsRefreshRoute.test.ts +667 -0
- package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +294 -6
- package/Tests/Server/API/ProxmoxResourceAPIRemoveNode.test.ts +1932 -0
- package/Tests/Server/Infrastructure/GlobalCache.test.ts +195 -0
- package/Tests/Server/Infrastructure/Postgres/AddKubernetesAiAgentAndAiDefaultsMigration.test.ts +623 -0
- package/Tests/Server/Infrastructure/Postgres/AddKubernetesClusterAiAccessMigration.test.ts +87 -10
- package/Tests/Server/Infrastructure/Postgres/AddTeamComplianceRuleScopeMigration.test.ts +622 -0
- package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +61 -4
- package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +98 -2
- package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +105 -2
- package/Tests/Server/Services/AIServiceBalanceBlocker.test.ts +282 -0
- package/Tests/Server/Services/AlertMonitorClearWritesNull.test.ts +409 -0
- package/Tests/Server/Services/AlertMonitorEditFeed.test.ts +474 -0
- package/Tests/Server/Services/AlertMonitorEditGuard.test.ts +610 -0
- package/Tests/Server/Services/AutoRemediationClusterRuleEngine.test.ts +2 -0
- package/Tests/Server/Services/AutoRemediationRuleCommandRunners.test.ts +2 -1
- package/Tests/Server/Services/IncidentAffectedMonitorsEdit.test.ts +1360 -0
- package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +3 -1
- package/Tests/Server/Services/IncidentSeverityDeletePausesComplianceRules.test.ts +488 -0
- package/Tests/Server/Services/KubernetesAiAgentJobService.test.ts +329 -0
- package/Tests/Server/Services/KubernetesAiAgentServiceHelpers.test.ts +618 -0
- package/Tests/Server/Services/KubernetesAiAgentServiceLifecycle.test.ts +1444 -0
- package/Tests/Server/Services/KubernetesAiAgentServiceRegister.test.ts +1666 -0
- package/Tests/Server/Services/KubernetesClusterAiAccessDeletedRunnerRemedy.test.ts +140 -111
- package/Tests/Server/Services/KubernetesClusterAiAccessGapNextSteps.test.ts +267 -105
- package/Tests/Server/Services/KubernetesClusterAiAccessLegacySuperseded.test.ts +440 -0
- package/Tests/Server/Services/KubernetesClusterAiAccessService.test.ts +567 -411
- package/Tests/Server/Services/KubernetesClusterAiAccessTarget.test.ts +1168 -0
- package/Tests/Server/Services/KubernetesClusterAiSettingsPermission.test.ts +410 -12
- package/Tests/Server/Services/KubernetesClusterServiceAiAccessFeed.test.ts +55 -0
- package/Tests/Server/Services/ProjectServiceNewProjectAiDefaults.test.ts +288 -0
- package/Tests/Server/Services/ProxmoxResourcePostgres.test.ts +5633 -0
- package/Tests/Server/Services/ProxmoxResourceService.test.ts +6458 -5
- package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +48 -15
- package/Tests/Server/Services/RunnerJobEnqueueKubectlAiAgent.test.ts +620 -0
- package/Tests/Server/Services/RunnerJobKubectlWriteScope.test.ts +170 -3
- package/Tests/Server/Services/RunnerJobTimeoutReason.test.ts +72 -0
- package/Tests/Server/Services/RunnerServiceAgentName.test.ts +6 -4
- package/Tests/Server/Services/ScheduledMaintenanceAttachmentEdit.test.ts +2113 -0
- package/Tests/Server/Services/ScheduledMaintenanceMonitorRelease.test.ts +972 -0
- package/Tests/Server/Services/TeamComplianceSettingPostgres.test.ts +2237 -0
- package/Tests/Server/Services/TeamComplianceSettingService.test.ts +3988 -138
- package/Tests/Server/Utils/AI/ClusterAccessContext.test.ts +107 -1
- package/Tests/Server/Utils/AI/IncidentPostmortemRunner.test.ts +351 -0
- package/Tests/Server/Utils/AI/InvestigationClusterAccessWiring.test.ts +2 -0
- package/Tests/Server/Utils/AI/InvestigationEligibility.test.ts +310 -0
- package/Tests/Server/Utils/AI/KubectlInvestigationToolkit.test.ts +70 -0
- package/Tests/Server/Utils/AI/KubectlJobRunnerFinishedJob.test.ts +58 -0
- package/Tests/Server/Utils/AI/RemediationCommandToolsKubectl.test.ts +2 -0
- package/Tests/Server/Utils/AI/RemediationCommandToolsKubectlOutcome.test.ts +2 -0
- package/Tests/Server/Utils/AI/RemediationCommandToolsLiveCluster.test.ts +2 -0
- package/Tests/Server/Utils/AI/RemediationCommandToolsRunnerScope.test.ts +125 -1
- package/Tests/Server/Utils/AI/RemediationExecutionRunner.test.ts +252 -3
- package/Tests/Server/Utils/AI/RemediationExecutionRunnerClusterMode.test.ts +2 -0
- package/Tests/Server/Utils/AI/RemediationExecutionRunnerProjectGates.test.ts +240 -0
- package/Tests/Server/Utils/AI/RemediationKubectlCopyParity.test.ts +2 -0
- package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutor.test.ts +2 -0
- package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorUnknownRun.test.ts +2 -0
- package/Tests/Server/Utils/AutoRemediation/CommandPlanVerification.test.ts +2 -0
- package/Tests/Server/Utils/Telemetry/HostAssetAttributesFromOtlp.test.ts +68 -0
- package/Tests/Server/Utils/Telemetry/InventoryMirrorPostgres.test.ts +370 -0
- package/Tests/Server/Utils/Telemetry/NetworkDeviceInventoryAttributes.test.ts +522 -0
- package/Tests/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.test.ts +155 -0
- package/Tests/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.test.ts +8631 -0
- package/Tests/Server/Utils/Telemetry/ProxmoxNativePush.test.ts +2852 -0
- package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +367 -1
- package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +45 -3
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +25 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsGroupChatNames.test.ts +3119 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsGroupChatNamesPostgres.test.ts +1721 -0
- package/Tests/Types/Kubernetes/KubernetesAgentChartAiAccess.test.ts +581 -113
- package/Tests/Types/Kubernetes/KubernetesAiAgentAccessContract.test.ts +510 -0
- package/Tests/Types/Team/ComplianceRule.test.ts +1029 -0
- package/Tests/Types/Team/TeamComplianceStatus.test.ts +176 -0
- package/Tests/UI/Components/ActionButton/RowActions.test.tsx +641 -0
- package/Tests/UI/Components/ActionButton/RowActionsAccessibility.test.tsx +414 -0
- package/Tests/UI/Components/ActionButton/SplitActionButtons.test.ts +565 -0
- package/Tests/UI/Components/Charts/BarChartDragToSelect.test.tsx +386 -0
- package/Tests/UI/Components/Charts/ChartDoubleClickResetWithoutDblclick.test.tsx +1025 -0
- package/Tests/UI/Components/Charts/ChartGroupPageZoomHint.test.tsx +196 -0
- package/Tests/UI/Components/Charts/ChartPlaceholdersTakeDoubleClick.test.tsx +1861 -0
- package/Tests/UI/Components/Charts/ChartPlotClicksWhileResetArmed.test.tsx +382 -0
- package/Tests/UI/Components/Charts/ChartRangeSelection.test.tsx +704 -0
- package/Tests/UI/Components/Charts/ChartRangeSelectionDoubleClickReset.test.tsx +1481 -0
- package/Tests/UI/Components/Charts/ChartRangeSelectionRealRecharts.test.tsx +918 -0
- package/Tests/UI/Components/Charts/ChartRowBucketStart.test.ts +350 -0
- package/Tests/UI/Components/Charts/DoubleClickReset.test.tsx +996 -0
- package/Tests/UI/Components/Charts/HistogramDoubleClickResetRealRecharts.test.tsx +965 -0
- package/Tests/UI/Components/Charts/HistogramDriftThenRefetch.test.tsx +277 -0
- package/Tests/UI/Components/Charts/HistogramRangeSelectionDataChange.test.tsx +1757 -0
- package/Tests/UI/Components/Charts/HistogramRangeSelectionRealRecharts.test.tsx +560 -0
- package/Tests/UI/Components/Charts/TimeRangeZoom/ChartPageTimeRangeZoom.test.tsx +513 -0
- package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.test.tsx +845 -0
- package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.test.tsx +119 -0
- package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHintTailwind.test.tsx +274 -0
- package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.test.ts +433 -0
- package/Tests/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.test.tsx +863 -0
- package/Tests/UI/Components/Charts/TimeRangeZoom/ZoomDocsPromises.test.tsx +1147 -0
- package/Tests/UI/Components/DisabledButtonTooltip.test.tsx +14 -3
- package/Tests/UI/Components/EntityDropdownEscape.test.tsx +454 -0
- package/Tests/UI/Components/EntityDropdownFocusLeave.test.tsx +420 -0
- package/Tests/UI/Components/Forms/FormFieldDropdownDisabled.test.tsx +233 -0
- package/Tests/UI/Components/Input.test.tsx +80 -1
- package/Tests/UI/Components/LogsAnalyticsViewZoom.test.tsx +947 -0
- package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +17 -5
- package/Tests/UI/Components/LogsViewerAnalyticsZoom.test.tsx +501 -0
- package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +1 -1
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +6 -4
- package/Tests/UI/Components/ModelTable/BaseModelTablePermissionGating.test.tsx +60 -8
- package/Tests/UI/Components/ModelTable/BaseModelTableRowActions.test.tsx +476 -0
- package/Tests/UI/Components/ModelTable/ModelTableWrapContent.test.tsx +7 -5
- package/Tests/UI/Components/MoreMenuPortal.test.tsx +623 -0
- package/Tests/UI/Components/StatusPage/ResourceGridActions.test.tsx +538 -0
- package/Tests/UI/Components/StatusPage/ResourceGridSelection.test.tsx +14 -4
- package/Tests/UI/Components/TableCellWrapping.test.tsx +3 -3
- package/Tests/UI/Components/TableRowActions.test.tsx +472 -0
- package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +1 -1
- package/Tests/UI/Components/TelemetryViewer/TelemetryViewerHistogramClickZoom.test.tsx +1 -1
- package/Tests/UI/Components/TelemetryViewer/TelemetryViewerZoomScope.test.tsx +470 -0
- package/Tests/UI/Components/TelemetryViewer/UseViewerTimeRangeZoom.test.tsx +571 -0
- package/Tests/UI/Components/TelemetryViewer/ViewerDoubleClickResetUnderDataChange.test.tsx +928 -0
- package/Tests/UI/Components/TelemetryViewer/ViewerFollowsEnclosingZoom.test.tsx +1569 -0
- package/Tests/UI/Components/Toggle.test.tsx +66 -0
- package/Tests/UI/MicrosoftTeams/MicrosoftTeamsChatsCardRefreshNames.test.tsx +1023 -0
- package/Tests/UI/Rum/ChunkLoader.test.ts +201 -0
- package/Tests/UI/Rum/FidelityNotices.test.ts +54 -0
- package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +72 -0
- package/Tests/UI/Rum/ReplayFrameCapture.test.ts +1009 -9
- package/Tests/UI/Rum/ReplayRecordedAssets.test.ts +1069 -0
- package/Tests/UI/Rum/ReplayStage.test.tsx +913 -2
- package/Tests/UI/Rum/SessionReplayTable.test.tsx +443 -8
- package/Tests/UI/Rum/SessionReplayUsersTable.test.tsx +439 -9
- package/Tests/UI/Rum/UserFlowTablesRowActions.test.tsx +501 -0
- package/Tests/Utils/AiRemediation/KubectlPolicyChartRbacParity.test.ts +47 -19
- package/Tests/Utils/AiRemediation/KubectlWriteScopeCallerParity.test.ts +2 -0
- package/Tests/Utils/AiRemediation/KubernetesAiAgentPolicyCopyParity.test.ts +396 -0
- package/Tests/Utils/Dashboard/DashboardTimeRangeZoom.test.ts +104 -1
- package/Types/AI/AIRunType.ts +9 -4
- package/Types/AI/InvestigationNotStartedReason.ts +6 -0
- package/Types/AutoRemediation/AiRemediationCommandPlan.ts +14 -16
- package/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.ts +28 -0
- package/Types/Dashboard/DashboardComponents/DashboardTraceChartComponent.ts +4 -2
- package/Types/Kubernetes/KubernetesClusterAiAccess.ts +263 -32
- package/Types/Kubernetes/KubernetesClusterAiAccessPermissions.ts +20 -16
- package/Types/Kubernetes/KubernetesClusterAiInsights.ts +81 -0
- package/Types/Monitor/ProxmoxAlertTemplates.ts +4 -4
- package/Types/Team/ComplianceNotificationChannel.ts +22 -0
- package/Types/Team/ComplianceRule.ts +434 -0
- package/Types/Team/ComplianceRuleType.ts +21 -0
- package/Types/Team/TeamComplianceStatus.ts +81 -0
- package/Types/Telemetry/TelemetryIngestSurface.ts +28 -0
- package/UI/Components/ActionButton/ActionButtonSchema.ts +21 -0
- package/UI/Components/ActionButton/RowActions.tsx +238 -0
- package/UI/Components/ActionButton/SplitActionButtons.ts +173 -0
- package/UI/Components/Charts/Area/AreaChart.tsx +32 -2
- package/UI/Components/Charts/Bar/BarChart.tsx +40 -3
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +37 -11
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +80 -243
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +122 -31
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +95 -244
- package/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.ts +214 -0
- package/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.ts +32 -0
- package/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.ts +599 -0
- package/UI/Components/Charts/Line/LineChart.tsx +32 -2
- package/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.tsx +70 -0
- package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.tsx +226 -0
- package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.tsx +73 -0
- package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.ts +163 -0
- package/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.ts +215 -0
- package/UI/Components/Charts/Utils/DataPoint.ts +20 -8
- package/UI/Components/Charts/Utils/XAxis.ts +75 -0
- package/UI/Components/Charts/Utils/useHistogramRangeSelection.ts +493 -59
- package/UI/Components/EntityDropdown/EntityDropdown.tsx +107 -11
- package/UI/Components/Forms/Fields/FormField.tsx +10 -0
- package/UI/Components/Input/Input.tsx +90 -57
- package/UI/Components/List/ListRow.tsx +8 -74
- package/UI/Components/LogsViewer/LogsViewer.tsx +40 -11
- package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +22 -6
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +209 -6
- package/UI/Components/LogsViewer/components/LogsHistogram.tsx +127 -84
- package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +3 -0
- package/UI/Components/ModelTable/BaseModelTable.tsx +12 -1
- package/UI/Components/MoreMenu/MoreMenu.tsx +365 -30
- package/UI/Components/MoreMenu/MoreMenuItem.tsx +70 -17
- package/UI/Components/OrderedStatesList/Item.tsx +7 -72
- package/UI/Components/StatusPage/ResourceGrid.tsx +126 -77
- package/UI/Components/Table/TableBody.tsx +8 -2
- package/UI/Components/Table/TableRow.tsx +20 -140
- package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +41 -10
- package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +47 -9
- package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +25 -6
- package/UI/Components/TelemetryViewer/types.ts +2 -0
- package/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.ts +448 -0
- package/UI/Components/Toggle/Toggle.tsx +26 -0
- package/UI/Components/Tooltip/Tooltip.tsx +8 -1
- package/UI/Styles/Theme.css +14 -2
- package/Utils/AiRemediation/CommandPolicy.ts +1 -1
- package/Utils/AiRemediation/KubectlPolicy.ts +1 -1
- package/Utils/Dashboard/DashboardTimeRangeZoom.ts +46 -2
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesAiAgent.js +385 -0
- package/build/dist/Models/DatabaseModels/KubernetesAiAgent.js.map +1 -0
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js +3 -3
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +56 -5
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ProxmoxResource.js +51 -1
- package/build/dist/Models/DatabaseModels/ProxmoxResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RunnerJob.js +74 -0
- package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js +134 -3
- package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
- package/build/dist/Server/API/AIChatAPI.js +6 -1
- package/build/dist/Server/API/AIChatAPI.js.map +1 -1
- package/build/dist/Server/API/AIInvestigationAPI.js +17 -10
- package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
- package/build/dist/Server/API/AutoRemediationAPI.js +144 -24
- package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
- package/build/dist/Server/API/KubernetesClusterAiAccessAPI.js +436 -22
- package/build/dist/Server/API/KubernetesClusterAiAccessAPI.js.map +1 -1
- package/build/dist/Server/API/MicrosoftTeamsAPI.js +69 -13
- package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
- package/build/dist/Server/API/ProxmoxResourceAPI.js +187 -14
- package/build/dist/Server/API/ProxmoxResourceAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/GlobalCache.js +27 -0
- package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.js +50 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.js +84 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.js +31 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/TelemetryIngest.js +34 -19
- package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
- package/build/dist/Server/Services/AIService.js +59 -1
- package/build/dist/Server/Services/AIService.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +248 -13
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/AlertSeverityService.js +51 -15
- package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
- package/build/dist/Server/Services/AutoRemediationRuleService.js +1 -1
- package/build/dist/Server/Services/AutoRemediationRuleService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +199 -72
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IncidentSeverityService.js +51 -15
- package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesAiAgentJobService.js +158 -0
- package/build/dist/Server/Services/KubernetesAiAgentJobService.js.map +1 -0
- package/build/dist/Server/Services/KubernetesAiAgentService.js +1145 -0
- package/build/dist/Server/Services/KubernetesAiAgentService.js.map +1 -0
- package/build/dist/Server/Services/KubernetesClusterAiAccessService.js +842 -371
- package/build/dist/Server/Services/KubernetesClusterAiAccessService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesClusterService.js +86 -17
- package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +22 -0
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxResourceService.js +258 -3
- package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
- package/build/dist/Server/Services/RunnerJobService.js +178 -92
- package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
- package/build/dist/Server/Services/RunnerService.js +1 -1
- package/build/dist/Server/Services/RunnerService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceService.js +535 -3
- package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +316 -46
- package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/TeamComplianceSettingService.js +833 -7
- package/build/dist/Server/Services/TeamComplianceSettingService.js.map +1 -1
- package/build/dist/Server/Utils/AI/ClusterAccess/ClusterAccessContext.js +52 -9
- package/build/dist/Server/Utils/AI/ClusterAccess/ClusterAccessContext.js.map +1 -1
- package/build/dist/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.js +13 -9
- package/build/dist/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.js.map +1 -1
- package/build/dist/Server/Utils/AI/ClusterAccess/KubectlJobRunner.js +7 -3
- package/build/dist/Server/Utils/AI/ClusterAccess/KubectlJobRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +29 -15
- package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +56 -20
- package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +39 -5
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/IncidentPostmortemRunner.js +30 -3
- package/build/dist/Server/Utils/AI/SRE/IncidentPostmortemRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/InvestigationEligibility.js +13 -2
- package/build/dist/Server/Utils/AI/SRE/InvestigationEligibility.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/InvestigationGrader.js +1 -1
- package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +4 -2
- package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +111 -6
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +40 -4
- package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.js +296 -0
- package/build/dist/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/ProxmoxNativePush.js +699 -0
- package/build/dist/Server/Utils/Telemetry/ProxmoxNativePush.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +77 -19
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +505 -42
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/AI/AIRunType.js +9 -4
- package/build/dist/Types/AI/AIRunType.js.map +1 -1
- package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +10 -16
- package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
- package/build/dist/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.js +28 -0
- package/build/dist/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.js.map +1 -0
- package/build/dist/Types/Kubernetes/KubernetesClusterAiAccess.js +72 -16
- package/build/dist/Types/Kubernetes/KubernetesClusterAiAccess.js.map +1 -1
- package/build/dist/Types/Kubernetes/KubernetesClusterAiAccessPermissions.js +20 -16
- package/build/dist/Types/Kubernetes/KubernetesClusterAiAccessPermissions.js.map +1 -1
- package/build/dist/Types/Kubernetes/KubernetesClusterAiInsights.js +17 -0
- package/build/dist/Types/Kubernetes/KubernetesClusterAiInsights.js.map +1 -0
- package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js +4 -4
- package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js.map +1 -1
- package/build/dist/Types/Team/ComplianceNotificationChannel.js +23 -0
- package/build/dist/Types/Team/ComplianceNotificationChannel.js.map +1 -0
- package/build/dist/Types/Team/ComplianceRule.js +304 -0
- package/build/dist/Types/Team/ComplianceRule.js.map +1 -0
- package/build/dist/Types/Team/ComplianceRuleType.js +21 -0
- package/build/dist/Types/Team/ComplianceRuleType.js.map +1 -1
- package/build/dist/Types/Team/TeamComplianceStatus.js +2 -0
- package/build/dist/Types/Team/TeamComplianceStatus.js.map +1 -0
- package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +25 -0
- package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
- package/build/dist/UI/Components/ActionButton/ActionButtonSchema.js +20 -1
- package/build/dist/UI/Components/ActionButton/ActionButtonSchema.js.map +1 -1
- package/build/dist/UI/Components/ActionButton/RowActions.js +113 -0
- package/build/dist/UI/Components/ActionButton/RowActions.js.map +1 -0
- package/build/dist/UI/Components/ActionButton/SplitActionButtons.js +71 -0
- package/build/dist/UI/Components/ActionButton/SplitActionButtons.js.map +1 -0
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +16 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +16 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +27 -5
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +61 -194
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +84 -26
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +75 -196
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js +133 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.js +26 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.js.map +1 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.js +371 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.js.map +1 -0
- package/build/dist/UI/Components/Charts/Line/LineChart.js +16 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.js +35 -0
- package/build/dist/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.js.map +1 -0
- package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.js +107 -0
- package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.js.map +1 -0
- package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.js +41 -0
- package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.js.map +1 -0
- package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.js +117 -0
- package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.js.map +1 -0
- package/build/dist/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.js +121 -0
- package/build/dist/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.js.map +1 -0
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js +15 -7
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/XAxis.js +70 -0
- package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/useHistogramRangeSelection.js +305 -38
- package/build/dist/UI/Components/Charts/Utils/useHistogramRangeSelection.js.map +1 -1
- package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js +89 -14
- package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -2
- package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +69 -54
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/UI/Components/List/ListRow.js +4 -39
- package/build/dist/UI/Components/List/ListRow.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +31 -8
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +10 -1
- package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +139 -21
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +30 -8
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +10 -0
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/MoreMenu/MoreMenu.js +253 -14
- package/build/dist/UI/Components/MoreMenu/MoreMenu.js.map +1 -1
- package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js +43 -13
- package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js.map +1 -1
- package/build/dist/UI/Components/OrderedStatesList/Item.js +2 -38
- package/build/dist/UI/Components/OrderedStatesList/Item.js.map +1 -1
- package/build/dist/UI/Components/StatusPage/ResourceGrid.js +53 -15
- package/build/dist/UI/Components/StatusPage/ResourceGrid.js.map +1 -1
- package/build/dist/UI/Components/Table/TableBody.js +6 -2
- package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
- package/build/dist/UI/Components/Table/TableRow.js +12 -75
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +32 -7
- package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +27 -9
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +11 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.js +272 -0
- package/build/dist/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.js.map +1 -0
- package/build/dist/UI/Components/Toggle/Toggle.js +14 -1
- package/build/dist/UI/Components/Toggle/Toggle.js.map +1 -1
- package/build/dist/UI/Components/Tooltip/Tooltip.js +1 -1
- package/build/dist/UI/Components/Tooltip/Tooltip.js.map +1 -1
- package/build/dist/Utils/AiRemediation/CommandPolicy.js +1 -1
- package/build/dist/Utils/AiRemediation/CommandPolicy.js.map +1 -1
- package/build/dist/Utils/AiRemediation/KubectlPolicy.js +1 -1
- package/build/dist/Utils/AiRemediation/KubectlPolicy.js.map +1 -1
- package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js +27 -1
- package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1932 @@
|
|
|
1
|
+
import { mockRouter } from "./Helpers";
|
|
2
|
+
import CommonAPI from "../../../Server/API/CommonAPI";
|
|
3
|
+
import ProxmoxResourceAPI from "../../../Server/API/ProxmoxResourceAPI";
|
|
4
|
+
import UserMiddleware from "../../../Server/Middleware/UserAuthorization";
|
|
5
|
+
import ProxmoxResourceService, {
|
|
6
|
+
ProxmoxInventorySummary,
|
|
7
|
+
ProxmoxRemoveNodeResult,
|
|
8
|
+
} from "../../../Server/Services/ProxmoxResourceService";
|
|
9
|
+
import ProxmoxClusterService from "../../../Server/Services/ProxmoxClusterService";
|
|
10
|
+
import ModelPermission from "../../../Server/Types/Database/Permissions/Index";
|
|
11
|
+
import {
|
|
12
|
+
ExpressRequest,
|
|
13
|
+
ExpressResponse,
|
|
14
|
+
NextFunction,
|
|
15
|
+
} from "../../../Server/Utils/Express";
|
|
16
|
+
import Response from "../../../Server/Utils/Response";
|
|
17
|
+
import Label from "../../../Models/DatabaseModels/Label";
|
|
18
|
+
import ProxmoxCluster from "../../../Models/DatabaseModels/ProxmoxCluster";
|
|
19
|
+
import ProxmoxResource from "../../../Models/DatabaseModels/ProxmoxResource";
|
|
20
|
+
import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
21
|
+
import Dictionary from "../../../Types/Dictionary";
|
|
22
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
23
|
+
import Exception from "../../../Types/Exception/Exception";
|
|
24
|
+
import NotAuthenticatedException from "../../../Types/Exception/NotAuthenticatedException";
|
|
25
|
+
import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
|
|
26
|
+
import NotFoundException from "../../../Types/Exception/NotFoundException";
|
|
27
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
28
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
29
|
+
import Permission, {
|
|
30
|
+
UserPermission,
|
|
31
|
+
UserTenantAccessPermission,
|
|
32
|
+
} from "../../../Types/Permission";
|
|
33
|
+
import UserType from "../../../Types/UserType";
|
|
34
|
+
import {
|
|
35
|
+
afterEach,
|
|
36
|
+
beforeAll,
|
|
37
|
+
beforeEach,
|
|
38
|
+
describe,
|
|
39
|
+
expect,
|
|
40
|
+
test,
|
|
41
|
+
} from "@jest/globals";
|
|
42
|
+
import fs from "fs";
|
|
43
|
+
import path from "path";
|
|
44
|
+
import { FindOperator } from "typeorm";
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* POST /proxmox-resource/remove-node/:clusterId { nodeName }
|
|
48
|
+
*
|
|
49
|
+
* On the Proxmox VE native push every node reports only itself, and a node
|
|
50
|
+
* that stops reporting is reported as down by its live siblings. A node
|
|
51
|
+
* taken out of the Proxmox cluster for good looks exactly the same, so the
|
|
52
|
+
* Dashboard lets a user remove it. This route is that button's server side.
|
|
53
|
+
*
|
|
54
|
+
* It runs behind UserMiddleware only, so it proves its own authorisation:
|
|
55
|
+
* the caller must be allowed to UPDATE the ProxmoxCluster (the inventory
|
|
56
|
+
* table has no user write access at all). The update permission check
|
|
57
|
+
* scopes the cluster lookup the way an update of that row would be scoped,
|
|
58
|
+
* and the row is then read as root. A cluster that does not exist, one in
|
|
59
|
+
* another project and one the caller may not edit all come back as the
|
|
60
|
+
* same NotFound, so the route cannot be used to probe for ids.
|
|
61
|
+
*
|
|
62
|
+
* The per-row check (the team block list) needs every label on the
|
|
63
|
+
* cluster. The scoped lookup is narrowed to the caller's permitted labels
|
|
64
|
+
* when the grant is label-scoped, so labels loaded through it would hide a
|
|
65
|
+
* label the caller's team is blocked on. The row check therefore loads the
|
|
66
|
+
* cluster again as root, labels unfiltered (ProxmoxClusterService
|
|
67
|
+
* .findOneById), the way updateOneById does. It loads it once per request,
|
|
68
|
+
* though under a labelled grant and a labelled block the block check and
|
|
69
|
+
* the allow check both ask for it, and never reuses one request's load in
|
|
70
|
+
* the next. A cluster deleted between the scoped lookup and that load
|
|
71
|
+
* answers the same NotFound as a missing one.
|
|
72
|
+
*
|
|
73
|
+
* The removal itself is ProxmoxResourceService.removeOfflineNode, which
|
|
74
|
+
* only ever deletes a native-push Node row that is not up, and says why
|
|
75
|
+
* when it deletes nothing: "not-found" answers 404, "not-native" (an agent
|
|
76
|
+
* node) and "still-reporting" answer 400 with an explanation, "removed"
|
|
77
|
+
* answers 200. Anything else — an unknown string, or a boolean from a
|
|
78
|
+
* stale mock of the old boolean contract — answers 400, never 200.
|
|
79
|
+
*
|
|
80
|
+
* The services are stubbed at their public seams; the permission check is
|
|
81
|
+
* stubbed in most tests and run for real in "real update permission check",
|
|
82
|
+
* where the scoped lookup is stubbed to serve the row the way the database
|
|
83
|
+
* would serve it through the label predicate the real check adds.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
jest.mock("../../../Server/Utils/Express", () => {
|
|
87
|
+
return {
|
|
88
|
+
getRouter: () => {
|
|
89
|
+
return mockRouter;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
jest.mock("../../../Server/Utils/Response", () => {
|
|
95
|
+
return {
|
|
96
|
+
sendEntityArrayResponse: jest.fn().mockImplementation((...args: []) => {
|
|
97
|
+
return args;
|
|
98
|
+
}),
|
|
99
|
+
sendJsonObjectResponse: jest.fn().mockImplementation((...args: []) => {
|
|
100
|
+
return args;
|
|
101
|
+
}),
|
|
102
|
+
sendEmptySuccessResponse: jest.fn(),
|
|
103
|
+
sendEntityResponse: jest.fn().mockImplementation((...args: []) => {
|
|
104
|
+
return args;
|
|
105
|
+
}),
|
|
106
|
+
sendErrorResponse: jest.fn().mockImplementation((...args: []) => {
|
|
107
|
+
return args;
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const REMOVE_NODE_ROUTE: string = "/proxmox-resource/remove-node/:clusterId";
|
|
113
|
+
const INVENTORY_SUMMARY_ROUTE: string =
|
|
114
|
+
"/proxmox-resource/inventory-summary/:clusterId";
|
|
115
|
+
|
|
116
|
+
const NOT_FOUND_MESSAGE: string =
|
|
117
|
+
"Proxmox Cluster not found, or you do not have permission to edit it. Removing a node needs permission to edit its cluster.";
|
|
118
|
+
|
|
119
|
+
const STILL_REPORTING_MESSAGE: string =
|
|
120
|
+
"Only a node that has stopped reporting can be removed. A node that is still reporting would come back on its next report.";
|
|
121
|
+
|
|
122
|
+
const NODE_NOT_FOUND_MESSAGE: string =
|
|
123
|
+
"This node is not in the cluster's inventory. It may already have been removed.";
|
|
124
|
+
|
|
125
|
+
const NOT_NATIVE_MESSAGE: string =
|
|
126
|
+
"Only a node that reports over Proxmox VE's built-in metric push can be removed here. With the Proxmox Agent, a node leaves OneUptime on its own once the cluster no longer lists it.";
|
|
127
|
+
|
|
128
|
+
const CONTROL_CHARACTER_MESSAGE: string =
|
|
129
|
+
"Node name must not contain control characters";
|
|
130
|
+
|
|
131
|
+
const REMOVED: ProxmoxRemoveNodeResult = "removed";
|
|
132
|
+
|
|
133
|
+
// How the per-row check must load the cluster: every label, as root.
|
|
134
|
+
const UNFILTERED_LOAD_SELECT: JSONObject = {
|
|
135
|
+
_id: true,
|
|
136
|
+
projectId: true,
|
|
137
|
+
labels: { _id: true },
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
interface RouteCall {
|
|
141
|
+
thrown: unknown;
|
|
142
|
+
nextCallCount: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type PermissionGrant = {
|
|
146
|
+
permission: Permission;
|
|
147
|
+
isBlockPermission?: boolean | undefined;
|
|
148
|
+
labelIds?: Array<ObjectID> | undefined;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// What the route hands the per-row update check.
|
|
152
|
+
type ByModelCheckInput = Parameters<
|
|
153
|
+
typeof ModelPermission.checkUpdatePermissionByModel
|
|
154
|
+
>[0];
|
|
155
|
+
|
|
156
|
+
type FetchedForCheck = ReturnType<
|
|
157
|
+
ByModelCheckInput["fetchModelWithAccessControlIds"]
|
|
158
|
+
>;
|
|
159
|
+
|
|
160
|
+
// How often the per-row check asked for the cluster with its labels.
|
|
161
|
+
interface LabelAsks {
|
|
162
|
+
count: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe("ProxmoxResourceAPI remove-node", () => {
|
|
166
|
+
let clusterId: ObjectID;
|
|
167
|
+
let projectId: ObjectID;
|
|
168
|
+
let props: DatabaseCommonInteractionProps;
|
|
169
|
+
let mockResponse: ExpressResponse;
|
|
170
|
+
let nextFunction: NextFunction;
|
|
171
|
+
let findOneBy: jest.SpyInstance;
|
|
172
|
+
let findOneById: jest.SpyInstance;
|
|
173
|
+
let removeOfflineNode: jest.SpyInstance;
|
|
174
|
+
let getInventorySummary: jest.SpyInstance;
|
|
175
|
+
let checkUpdateQueryPermissions: jest.SpyInstance;
|
|
176
|
+
let checkUpdatePermissionByModel: jest.SpyInstance;
|
|
177
|
+
|
|
178
|
+
beforeAll(() => {
|
|
179
|
+
mockRouter.routes.length = 0;
|
|
180
|
+
new ProxmoxResourceAPI();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
jest.clearAllMocks();
|
|
185
|
+
|
|
186
|
+
clusterId = ObjectID.generate();
|
|
187
|
+
projectId = ObjectID.generate();
|
|
188
|
+
props = {
|
|
189
|
+
isRoot: false,
|
|
190
|
+
userId: ObjectID.generate(),
|
|
191
|
+
tenantId: projectId,
|
|
192
|
+
} as DatabaseCommonInteractionProps;
|
|
193
|
+
|
|
194
|
+
jest
|
|
195
|
+
.spyOn(CommonAPI, "getDatabaseCommonInteractionProps")
|
|
196
|
+
.mockImplementation(async (): Promise<DatabaseCommonInteractionProps> => {
|
|
197
|
+
return props;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/*
|
|
201
|
+
* The caller may edit the cluster unless a test says otherwise; the
|
|
202
|
+
* query comes back as it went in. "real update permission check"
|
|
203
|
+
* restores the real check.
|
|
204
|
+
*/
|
|
205
|
+
checkUpdateQueryPermissions = jest
|
|
206
|
+
.spyOn(ModelPermission, "checkUpdateQueryPermissions")
|
|
207
|
+
.mockImplementation(async (_modelType: unknown, query: unknown) => {
|
|
208
|
+
return query as never;
|
|
209
|
+
});
|
|
210
|
+
checkUpdatePermissionByModel = jest
|
|
211
|
+
.spyOn(ModelPermission, "checkUpdatePermissionByModel")
|
|
212
|
+
.mockResolvedValue(undefined);
|
|
213
|
+
|
|
214
|
+
// Nothing here may reach Postgres.
|
|
215
|
+
findOneBy = jest
|
|
216
|
+
.spyOn(ProxmoxClusterService, "findOneBy")
|
|
217
|
+
.mockResolvedValue(null);
|
|
218
|
+
findOneById = jest
|
|
219
|
+
.spyOn(ProxmoxClusterService, "findOneById")
|
|
220
|
+
.mockResolvedValue(null);
|
|
221
|
+
removeOfflineNode = jest
|
|
222
|
+
.spyOn(ProxmoxResourceService, "removeOfflineNode")
|
|
223
|
+
.mockResolvedValue(REMOVED);
|
|
224
|
+
getInventorySummary = jest
|
|
225
|
+
.spyOn(ProxmoxResourceService, "getInventorySummary")
|
|
226
|
+
.mockRejectedValue(new Error("the summary is not part of remove-node"));
|
|
227
|
+
|
|
228
|
+
mockResponse = {
|
|
229
|
+
cookie: jest.fn(),
|
|
230
|
+
send: jest.fn(),
|
|
231
|
+
json: jest.fn(),
|
|
232
|
+
status: jest.fn().mockReturnThis(),
|
|
233
|
+
} as unknown as ExpressResponse;
|
|
234
|
+
nextFunction = jest.fn();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
jest.restoreAllMocks();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
function clusterRow(labelIds: Array<ObjectID> = []): ProxmoxCluster {
|
|
242
|
+
const cluster: ProxmoxCluster = new ProxmoxCluster();
|
|
243
|
+
cluster.id = clusterId;
|
|
244
|
+
cluster.projectId = projectId;
|
|
245
|
+
cluster.labels = labelIds.map((labelId: ObjectID): Label => {
|
|
246
|
+
const label: Label = new Label();
|
|
247
|
+
label.id = labelId;
|
|
248
|
+
return label;
|
|
249
|
+
});
|
|
250
|
+
return cluster;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function labelIdsOf(cluster: unknown): Array<string> {
|
|
254
|
+
return ((cluster as ProxmoxCluster).labels || []).map(
|
|
255
|
+
(label: Label): string => {
|
|
256
|
+
return String(label.id);
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildRequest(
|
|
262
|
+
clusterIdParam: string | undefined,
|
|
263
|
+
body: unknown,
|
|
264
|
+
): ExpressRequest {
|
|
265
|
+
return {
|
|
266
|
+
params: clusterIdParam === undefined ? {} : { clusterId: clusterIdParam },
|
|
267
|
+
body: body,
|
|
268
|
+
query: {},
|
|
269
|
+
cookies: {},
|
|
270
|
+
headers: {},
|
|
271
|
+
socket: {},
|
|
272
|
+
ips: [],
|
|
273
|
+
} as unknown as ExpressRequest;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function callRoute(
|
|
277
|
+
clusterIdParam: string | undefined,
|
|
278
|
+
body: unknown,
|
|
279
|
+
): Promise<RouteCall> {
|
|
280
|
+
await mockRouter
|
|
281
|
+
.match("post", REMOVE_NODE_ROUTE)
|
|
282
|
+
.handlerFunction(
|
|
283
|
+
buildRequest(clusterIdParam, body),
|
|
284
|
+
mockResponse,
|
|
285
|
+
nextFunction,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const calls: Array<Array<unknown>> = (nextFunction as jest.Mock).mock
|
|
289
|
+
.calls as Array<Array<unknown>>;
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
thrown: calls[0]?.[0],
|
|
293
|
+
nextCallCount: calls.length,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function sentBody(): JSONObject {
|
|
298
|
+
const calls: Array<Array<unknown>> = (
|
|
299
|
+
Response.sendJsonObjectResponse as jest.Mock
|
|
300
|
+
).mock.calls as Array<Array<unknown>>;
|
|
301
|
+
expect(calls.length).toBe(1);
|
|
302
|
+
return calls[0]![2] as JSONObject;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function removeArgs(): JSONObject {
|
|
306
|
+
expect(removeOfflineNode).toHaveBeenCalledTimes(1);
|
|
307
|
+
return removeOfflineNode.mock.calls[0]![0] as JSONObject;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function expectNothingDone(): void {
|
|
311
|
+
expect(removeOfflineNode).not.toHaveBeenCalled();
|
|
312
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function expectRefusedAsNotFound(call: RouteCall): void {
|
|
316
|
+
expect(call.nextCallCount).toBe(1);
|
|
317
|
+
expect(call.thrown).toBeInstanceOf(NotFoundException);
|
|
318
|
+
expect((call.thrown as NotFoundException).message).toBe(NOT_FOUND_MESSAGE);
|
|
319
|
+
expectNothingDone();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
describe("registration", () => {
|
|
323
|
+
test("derives the route from the model's CRUD path and mounts it as POST", () => {
|
|
324
|
+
expect(new ProxmoxResource().getCrudApiPath()?.toString()).toBe(
|
|
325
|
+
"/proxmox-resource",
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const route: ReturnType<typeof mockRouter.match> = mockRouter.match(
|
|
329
|
+
"post",
|
|
330
|
+
REMOVE_NODE_ROUTE,
|
|
331
|
+
);
|
|
332
|
+
expect(route.method).toBe("POST");
|
|
333
|
+
|
|
334
|
+
for (const method of ["get", "put", "delete"]) {
|
|
335
|
+
expect(() => {
|
|
336
|
+
return mockRouter.match(method, REMOVE_NODE_ROUTE);
|
|
337
|
+
}).toThrow();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("is mounted behind UserMiddleware.getUserMiddleware only", () => {
|
|
342
|
+
const route: ReturnType<typeof mockRouter.match> = mockRouter.match(
|
|
343
|
+
"post",
|
|
344
|
+
REMOVE_NODE_ROUTE,
|
|
345
|
+
);
|
|
346
|
+
expect(route.middlewares).toEqual([UserMiddleware.getUserMiddleware]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("keeps the inventory-summary route next to it", () => {
|
|
350
|
+
const route: ReturnType<typeof mockRouter.match> = mockRouter.match(
|
|
351
|
+
"post",
|
|
352
|
+
INVENTORY_SUMMARY_ROUTE,
|
|
353
|
+
);
|
|
354
|
+
expect(route.middlewares).toEqual([UserMiddleware.getUserMiddleware]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("does not collide with the generic CRUD routes BaseAPI registers", () => {
|
|
358
|
+
/*
|
|
359
|
+
* BaseAPI mounts POST /proxmox-resource/:id/get-item and friends; the
|
|
360
|
+
* custom route has a literal second segment, so an id never matches it
|
|
361
|
+
* and it never matches them.
|
|
362
|
+
*/
|
|
363
|
+
const genericPostRoutes: Array<string> = mockRouter.routes
|
|
364
|
+
.filter((route: { method: string; uri: string }) => {
|
|
365
|
+
return route.method === "POST";
|
|
366
|
+
})
|
|
367
|
+
.map((route: { uri: string }) => {
|
|
368
|
+
return route.uri;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
expect(genericPostRoutes).toContain("/proxmox-resource/:id/get-item");
|
|
372
|
+
expect(
|
|
373
|
+
genericPostRoutes.filter((uri: string) => {
|
|
374
|
+
return uri === REMOVE_NODE_ROUTE;
|
|
375
|
+
}),
|
|
376
|
+
).toHaveLength(1);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("cluster id validation", () => {
|
|
381
|
+
test("rejects a missing id before touching any service", async () => {
|
|
382
|
+
const call: RouteCall = await callRoute(undefined, { nodeName: "pve1" });
|
|
383
|
+
|
|
384
|
+
expect(call.nextCallCount).toBe(1);
|
|
385
|
+
expect(call.thrown).toBeInstanceOf(BadDataException);
|
|
386
|
+
expect((call.thrown as BadDataException).message).toBe(
|
|
387
|
+
"Cluster ID is required",
|
|
388
|
+
);
|
|
389
|
+
expect(
|
|
390
|
+
CommonAPI.getDatabaseCommonInteractionProps,
|
|
391
|
+
).not.toHaveBeenCalled();
|
|
392
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
393
|
+
expectNothingDone();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("rejects an empty id", async () => {
|
|
397
|
+
const call: RouteCall = await callRoute("", { nodeName: "pve1" });
|
|
398
|
+
|
|
399
|
+
expect(call.thrown).toBeInstanceOf(BadDataException);
|
|
400
|
+
expect((call.thrown as BadDataException).message).toBe(
|
|
401
|
+
"Cluster ID is required",
|
|
402
|
+
);
|
|
403
|
+
expectNothingDone();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test.each(["not-an-id", "1", "' OR 1=1 --", `${"a".repeat(36)}`])(
|
|
407
|
+
"rejects the malformed id %p before it reaches a uuid column",
|
|
408
|
+
async (malformed: string) => {
|
|
409
|
+
const call: RouteCall = await callRoute(malformed, {
|
|
410
|
+
nodeName: "pve1",
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(call.nextCallCount).toBe(1);
|
|
414
|
+
expect(call.thrown).toBeInstanceOf(BadDataException);
|
|
415
|
+
expect((call.thrown as BadDataException).message).toBe(
|
|
416
|
+
"Invalid Cluster ID",
|
|
417
|
+
);
|
|
418
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
419
|
+
expectNothingDone();
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe("node name validation", () => {
|
|
425
|
+
beforeEach(() => {
|
|
426
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test.each([
|
|
430
|
+
["no body", undefined],
|
|
431
|
+
["a null body", null],
|
|
432
|
+
["a string body", "pve1"],
|
|
433
|
+
["no nodeName", {}],
|
|
434
|
+
["a null nodeName", { nodeName: null }],
|
|
435
|
+
["a numeric nodeName", { nodeName: 1 }],
|
|
436
|
+
["a boolean nodeName", { nodeName: true }],
|
|
437
|
+
["an array nodeName", { nodeName: ["pve1"] }],
|
|
438
|
+
["an object nodeName", { nodeName: { name: "pve1" } }],
|
|
439
|
+
["an empty nodeName", { nodeName: "" }],
|
|
440
|
+
["a blank nodeName", { nodeName: " " }],
|
|
441
|
+
["a whitespace-only nodeName", { nodeName: "\t\n " }],
|
|
442
|
+
] as Array<[string, unknown]>)(
|
|
443
|
+
"answers 400 for %s",
|
|
444
|
+
async (_label: string, body: unknown) => {
|
|
445
|
+
const call: RouteCall = await callRoute(clusterId.toString(), body);
|
|
446
|
+
|
|
447
|
+
expect(call.nextCallCount).toBe(1);
|
|
448
|
+
expect(call.thrown).toBeInstanceOf(BadDataException);
|
|
449
|
+
expect((call.thrown as BadDataException).message).toBe(
|
|
450
|
+
"Node name is required",
|
|
451
|
+
);
|
|
452
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
453
|
+
expectNothingDone();
|
|
454
|
+
},
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
test.each([
|
|
458
|
+
"node/pve1",
|
|
459
|
+
"pve1/",
|
|
460
|
+
"/pve1",
|
|
461
|
+
"qemu/100",
|
|
462
|
+
"storage/pve1/local",
|
|
463
|
+
"../pve1",
|
|
464
|
+
" pve1/x ",
|
|
465
|
+
])(
|
|
466
|
+
"answers 400 for %p, which is not a single node name",
|
|
467
|
+
async (nodeName: string) => {
|
|
468
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
469
|
+
nodeName,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(call.nextCallCount).toBe(1);
|
|
473
|
+
expect(call.thrown).toBeInstanceOf(BadDataException);
|
|
474
|
+
expect((call.thrown as BadDataException).message).toBe(
|
|
475
|
+
"Node name must not contain a slash",
|
|
476
|
+
);
|
|
477
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
478
|
+
expectNothingDone();
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
test.each([
|
|
483
|
+
["a NUL", "pve\u00001"],
|
|
484
|
+
["a leading NUL, which trim() keeps", "\u0000pve1"],
|
|
485
|
+
["a tab inside the name", "pve\t1"],
|
|
486
|
+
["a newline inside the name", "pve\n1"],
|
|
487
|
+
["a carriage return inside the name", "pve\r1"],
|
|
488
|
+
["an ANSI escape", "pve1\u001b[31m"],
|
|
489
|
+
["U+001F, the last C0 control", "pve\u001f1"],
|
|
490
|
+
["DEL (U+007F)", "pve\u007f1"],
|
|
491
|
+
] as Array<[string, string]>)(
|
|
492
|
+
"answers 400 for a name with %s, before any lookup",
|
|
493
|
+
async (_label: string, nodeName: string) => {
|
|
494
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
495
|
+
nodeName,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
expect(call.nextCallCount).toBe(1);
|
|
499
|
+
expect(call.thrown).toBeInstanceOf(BadDataException);
|
|
500
|
+
expect((call.thrown as Exception).code).toBe(400);
|
|
501
|
+
expect((call.thrown as BadDataException).message).toBe(
|
|
502
|
+
CONTROL_CHARACTER_MESSAGE,
|
|
503
|
+
);
|
|
504
|
+
expect(
|
|
505
|
+
CommonAPI.getDatabaseCommonInteractionProps,
|
|
506
|
+
).not.toHaveBeenCalled();
|
|
507
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
508
|
+
expectNothingDone();
|
|
509
|
+
},
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
test("a name with both a slash and a control character is refused for the slash", async () => {
|
|
513
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
514
|
+
nodeName: "pve/\u00001",
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
expect((call.thrown as BadDataException).message).toBe(
|
|
518
|
+
"Node name must not contain a slash",
|
|
519
|
+
);
|
|
520
|
+
expectNothingDone();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test.each([
|
|
524
|
+
["a space (U+0020, the first printable)", "pve 1", "node/pve 1"],
|
|
525
|
+
[
|
|
526
|
+
"a tilde (U+007E, the last printable before DEL)",
|
|
527
|
+
"pve~1",
|
|
528
|
+
"node/pve~1",
|
|
529
|
+
],
|
|
530
|
+
["surrounding whitespace, which is trimmed", "\t pve1 \r\n", "node/pve1"],
|
|
531
|
+
] as Array<[string, string, string]>)(
|
|
532
|
+
"accepts a name with %s",
|
|
533
|
+
async (_label: string, nodeName: string, externalId: string) => {
|
|
534
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
535
|
+
nodeName,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
expect(call.nextCallCount).toBe(0);
|
|
539
|
+
expect(removeArgs()["externalId"]).toBe(externalId);
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
test("trims the name before building the externalId", async () => {
|
|
544
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
545
|
+
nodeName: " pve1 \n",
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
expect(call.nextCallCount).toBe(0);
|
|
549
|
+
expect(removeArgs()["externalId"]).toBe("node/pve1");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("keeps the name's own case and punctuation", async () => {
|
|
553
|
+
/*
|
|
554
|
+
* Node names are hostnames and are matched as-is against the
|
|
555
|
+
* inventory's externalId; nothing is lowercased or slugified.
|
|
556
|
+
*/
|
|
557
|
+
await callRoute(clusterId.toString(), { nodeName: "PVE-Node_02.lab" });
|
|
558
|
+
|
|
559
|
+
expect(removeArgs()["externalId"]).toBe("node/PVE-Node_02.lab");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("ignores any other body fields, including a caller-supplied externalId or project", async () => {
|
|
563
|
+
const otherProject: ObjectID = ObjectID.generate();
|
|
564
|
+
|
|
565
|
+
await callRoute(clusterId.toString(), {
|
|
566
|
+
nodeName: "pve1",
|
|
567
|
+
externalId: "qemu/100",
|
|
568
|
+
projectId: otherProject.toString(),
|
|
569
|
+
proxmoxClusterId: ObjectID.generate().toString(),
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const args: JSONObject = removeArgs();
|
|
573
|
+
expect(args["externalId"]).toBe("node/pve1");
|
|
574
|
+
expect((args["projectId"] as ObjectID).toString()).toBe(
|
|
575
|
+
projectId.toString(),
|
|
576
|
+
);
|
|
577
|
+
expect((args["proxmoxClusterId"] as ObjectID).toString()).toBe(
|
|
578
|
+
clusterId.toString(),
|
|
579
|
+
);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe("authorisation (update permission check stubbed)", () => {
|
|
584
|
+
const SCOPED_QUERY_MARKER: string = "scoped-by-permission-check";
|
|
585
|
+
|
|
586
|
+
beforeEach(() => {
|
|
587
|
+
// Stands in for the label / Owned predicates the real check adds.
|
|
588
|
+
checkUpdateQueryPermissions.mockImplementation(
|
|
589
|
+
async (_modelType: unknown, query: unknown) => {
|
|
590
|
+
return {
|
|
591
|
+
...(query as JSONObject),
|
|
592
|
+
labels: SCOPED_QUERY_MARKER,
|
|
593
|
+
} as never;
|
|
594
|
+
},
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("asks whether the caller may UPDATE this cluster in the caller's own project", async () => {
|
|
599
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
600
|
+
|
|
601
|
+
await callRoute(clusterId.toString(), { nodeName: "pve1" });
|
|
602
|
+
|
|
603
|
+
expect(CommonAPI.getDatabaseCommonInteractionProps).toHaveBeenCalledTimes(
|
|
604
|
+
1,
|
|
605
|
+
);
|
|
606
|
+
expect(checkUpdateQueryPermissions).toHaveBeenCalledTimes(1);
|
|
607
|
+
const [modelType, query, data, checkedProps] = checkUpdateQueryPermissions
|
|
608
|
+
.mock.calls[0]! as [unknown, JSONObject, JSONObject, unknown];
|
|
609
|
+
|
|
610
|
+
expect(modelType).toBe(ProxmoxCluster);
|
|
611
|
+
expect(query["_id"]).toBe(clusterId.toString());
|
|
612
|
+
expect((query["projectId"] as ObjectID).toString()).toBe(
|
|
613
|
+
projectId.toString(),
|
|
614
|
+
);
|
|
615
|
+
expect(Object.keys(query).sort()).toEqual(["_id", "projectId"]);
|
|
616
|
+
expect(data).toEqual({});
|
|
617
|
+
expect(checkedProps).toBe(props);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("reads the cluster as root through exactly the query the permission check returned", async () => {
|
|
621
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
622
|
+
|
|
623
|
+
await callRoute(clusterId.toString(), { nodeName: "pve1" });
|
|
624
|
+
|
|
625
|
+
expect(findOneBy).toHaveBeenCalledTimes(1);
|
|
626
|
+
const findArgs: JSONObject = findOneBy.mock.calls[0]![0] as JSONObject;
|
|
627
|
+
const query: JSONObject = findArgs["query"] as JSONObject;
|
|
628
|
+
|
|
629
|
+
expect(query["labels"]).toBe(SCOPED_QUERY_MARKER);
|
|
630
|
+
expect(query["_id"]).toBe(clusterId.toString());
|
|
631
|
+
expect((query["projectId"] as ObjectID).toString()).toBe(
|
|
632
|
+
projectId.toString(),
|
|
633
|
+
);
|
|
634
|
+
expect(findArgs["props"]).toEqual({ isRoot: true });
|
|
635
|
+
/*
|
|
636
|
+
* No labels through this lookup: under a label-scoped grant they
|
|
637
|
+
* would come back narrowed to the permitted ones and hide a blocked
|
|
638
|
+
* label from the per-row check below.
|
|
639
|
+
*/
|
|
640
|
+
expect(findArgs["select"]).toEqual({
|
|
641
|
+
_id: true,
|
|
642
|
+
projectId: true,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
/*
|
|
646
|
+
* Never the read-access lookup the inventory-summary route uses;
|
|
647
|
+
* with the per-row check stubbed, nothing asks for the root load.
|
|
648
|
+
*/
|
|
649
|
+
expect(findOneById).not.toHaveBeenCalled();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
test("then runs the per-row update check on the cluster loaded as root with every label, with the caller's props", async () => {
|
|
653
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
654
|
+
const withEveryLabel: ProxmoxCluster = clusterRow([
|
|
655
|
+
ObjectID.generate(),
|
|
656
|
+
ObjectID.generate(),
|
|
657
|
+
]);
|
|
658
|
+
findOneById.mockResolvedValue(withEveryLabel);
|
|
659
|
+
|
|
660
|
+
await callRoute(clusterId.toString(), { nodeName: "pve1" });
|
|
661
|
+
|
|
662
|
+
expect(checkUpdatePermissionByModel).toHaveBeenCalledTimes(1);
|
|
663
|
+
const byModelArgs: {
|
|
664
|
+
modelType: unknown;
|
|
665
|
+
props: unknown;
|
|
666
|
+
fetchModelWithAccessControlIds: () => Promise<unknown>;
|
|
667
|
+
} = checkUpdatePermissionByModel.mock.calls[0]![0];
|
|
668
|
+
|
|
669
|
+
expect(byModelArgs.modelType).toBe(ProxmoxCluster);
|
|
670
|
+
expect(byModelArgs.props).toBe(props);
|
|
671
|
+
|
|
672
|
+
expect(await byModelArgs.fetchModelWithAccessControlIds()).toBe(
|
|
673
|
+
withEveryLabel,
|
|
674
|
+
);
|
|
675
|
+
expect(findOneById).toHaveBeenCalledTimes(1);
|
|
676
|
+
const loadArgs: JSONObject = findOneById.mock.calls[0]![0] as JSONObject;
|
|
677
|
+
expect(Object.keys(loadArgs).sort()).toEqual(["id", "props", "select"]);
|
|
678
|
+
expect((loadArgs["id"] as ObjectID).toString()).toBe(
|
|
679
|
+
clusterId.toString(),
|
|
680
|
+
);
|
|
681
|
+
expect(loadArgs["select"]).toEqual(UNFILTERED_LOAD_SELECT);
|
|
682
|
+
// Unfiltered: root, never the caller's (label-scoped) props.
|
|
683
|
+
expect(loadArgs["props"]).toEqual({ isRoot: true });
|
|
684
|
+
expect(loadArgs["props"]).not.toBe(props);
|
|
685
|
+
// Not through the scoped query, which still ran exactly once.
|
|
686
|
+
expect(findOneBy).toHaveBeenCalledTimes(1);
|
|
687
|
+
|
|
688
|
+
// Both checks pass before anything is removed.
|
|
689
|
+
const queryCheckOrder: number =
|
|
690
|
+
checkUpdateQueryPermissions.mock.invocationCallOrder[0]!;
|
|
691
|
+
const byModelOrder: number =
|
|
692
|
+
checkUpdatePermissionByModel.mock.invocationCallOrder[0]!;
|
|
693
|
+
const removeOrder: number =
|
|
694
|
+
removeOfflineNode.mock.invocationCallOrder[0]!;
|
|
695
|
+
expect(queryCheckOrder).toBeLessThan(byModelOrder);
|
|
696
|
+
expect(byModelOrder).toBeLessThan(removeOrder);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("the per-row check sees the labels the scoped lookup would have hidden", async () => {
|
|
700
|
+
const permittedLabel: ObjectID = ObjectID.generate();
|
|
701
|
+
const blockedLabel: ObjectID = ObjectID.generate();
|
|
702
|
+
/*
|
|
703
|
+
* What a label-scoped lookup that also selected labels returns: only
|
|
704
|
+
* the permitted one. The root load has both.
|
|
705
|
+
*/
|
|
706
|
+
findOneBy.mockResolvedValue(clusterRow([permittedLabel]));
|
|
707
|
+
findOneById.mockResolvedValue(clusterRow([permittedLabel, blockedLabel]));
|
|
708
|
+
|
|
709
|
+
await callRoute(clusterId.toString(), { nodeName: "pve1" });
|
|
710
|
+
|
|
711
|
+
const fetchModel: () => Promise<unknown> = (
|
|
712
|
+
checkUpdatePermissionByModel.mock.calls[0]![0] as {
|
|
713
|
+
fetchModelWithAccessControlIds: () => Promise<unknown>;
|
|
714
|
+
}
|
|
715
|
+
).fetchModelWithAccessControlIds;
|
|
716
|
+
|
|
717
|
+
expect(labelIdsOf(await fetchModel())).toEqual([
|
|
718
|
+
permittedLabel.toString(),
|
|
719
|
+
blockedLabel.toString(),
|
|
720
|
+
]);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("loads the cluster with every label once per request, however often the per-row check asks", async () => {
|
|
724
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
725
|
+
const loaded: ProxmoxCluster = clusterRow([ObjectID.generate()]);
|
|
726
|
+
findOneById.mockResolvedValue(loaded);
|
|
727
|
+
|
|
728
|
+
// The block check and the allow check both ask; ask a third time too.
|
|
729
|
+
const answers: Array<unknown> = [];
|
|
730
|
+
checkUpdatePermissionByModel.mockImplementation(
|
|
731
|
+
async (data: ByModelCheckInput): Promise<void> => {
|
|
732
|
+
answers.push(await data.fetchModelWithAccessControlIds());
|
|
733
|
+
answers.push(await data.fetchModelWithAccessControlIds());
|
|
734
|
+
answers.push(await data.fetchModelWithAccessControlIds());
|
|
735
|
+
},
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
739
|
+
nodeName: "pve1",
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
expect(call.nextCallCount).toBe(0);
|
|
743
|
+
expect(findOneById).toHaveBeenCalledTimes(1);
|
|
744
|
+
expect(answers).toEqual([loaded, loaded, loaded]);
|
|
745
|
+
for (const answer of answers) {
|
|
746
|
+
expect(answer).toBe(loaded);
|
|
747
|
+
}
|
|
748
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("never reuses one request's labels load in the next request", async () => {
|
|
752
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
753
|
+
const first: ProxmoxCluster = clusterRow([ObjectID.generate()]);
|
|
754
|
+
const second: ProxmoxCluster = clusterRow([
|
|
755
|
+
ObjectID.generate(),
|
|
756
|
+
ObjectID.generate(),
|
|
757
|
+
]);
|
|
758
|
+
findOneById.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
|
|
759
|
+
|
|
760
|
+
const answers: Array<unknown> = [];
|
|
761
|
+
checkUpdatePermissionByModel.mockImplementation(
|
|
762
|
+
async (data: ByModelCheckInput): Promise<void> => {
|
|
763
|
+
answers.push(await data.fetchModelWithAccessControlIds());
|
|
764
|
+
answers.push(await data.fetchModelWithAccessControlIds());
|
|
765
|
+
},
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
await callRoute(clusterId.toString(), { nodeName: "pve1" });
|
|
769
|
+
await callRoute(clusterId.toString(), { nodeName: "pve1" });
|
|
770
|
+
|
|
771
|
+
// One load per request: labels changed in between are seen.
|
|
772
|
+
expect(findOneById).toHaveBeenCalledTimes(2);
|
|
773
|
+
expect(answers).toHaveLength(4);
|
|
774
|
+
expect(answers[0]).toBe(first);
|
|
775
|
+
expect(answers[1]).toBe(first);
|
|
776
|
+
expect(answers[2]).toBe(second);
|
|
777
|
+
expect(answers[3]).toBe(second);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("answers the same NotFound for a cluster deleted between the scoped lookup and the labels load", async () => {
|
|
781
|
+
// Found by the scoped lookup, gone by the time its labels are loaded.
|
|
782
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
783
|
+
findOneById.mockResolvedValue(null);
|
|
784
|
+
// Like the real check: it asks for the row before deciding.
|
|
785
|
+
checkUpdatePermissionByModel.mockImplementation(
|
|
786
|
+
async (data: ByModelCheckInput): Promise<void> => {
|
|
787
|
+
await data.fetchModelWithAccessControlIds();
|
|
788
|
+
},
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
792
|
+
nodeName: "pve1",
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
expectRefusedAsNotFound(call);
|
|
796
|
+
expect((call.thrown as Exception).code).toBe(404);
|
|
797
|
+
expect(findOneById).toHaveBeenCalledTimes(1);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("hands a failure of the labels load to next() unchanged", async () => {
|
|
801
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
802
|
+
const failure: Error = new Error("postgres is away");
|
|
803
|
+
findOneById.mockRejectedValue(failure);
|
|
804
|
+
checkUpdatePermissionByModel.mockImplementation(
|
|
805
|
+
async (data: ByModelCheckInput): Promise<void> => {
|
|
806
|
+
await data.fetchModelWithAccessControlIds();
|
|
807
|
+
},
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
811
|
+
nodeName: "pve1",
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
expect(call.nextCallCount).toBe(1);
|
|
815
|
+
expect(call.thrown).toBe(failure);
|
|
816
|
+
expectNothingDone();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("answers the same NotFound when the per-row check refuses (a team block list)", async () => {
|
|
820
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
821
|
+
checkUpdatePermissionByModel.mockRejectedValue(
|
|
822
|
+
new NotAuthorizedException(
|
|
823
|
+
"You are not authorized to update this Proxmox Cluster because EditProxmoxCluster is in your team's permission block list.",
|
|
824
|
+
),
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
828
|
+
nodeName: "pve1",
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// The block-list wording would confirm the cluster exists.
|
|
832
|
+
expectRefusedAsNotFound(call);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test("passes any other per-row failure through unchanged", async () => {
|
|
836
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
837
|
+
const failure: Error = new Error("label lookup failed");
|
|
838
|
+
checkUpdatePermissionByModel.mockRejectedValue(failure);
|
|
839
|
+
|
|
840
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
841
|
+
nodeName: "pve1",
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
expect(call.thrown).toBe(failure);
|
|
845
|
+
expectNothingDone();
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test("answers NotFound for a cluster that does not exist", async () => {
|
|
849
|
+
findOneBy.mockResolvedValue(null);
|
|
850
|
+
|
|
851
|
+
const call: RouteCall = await callRoute(ObjectID.generate().toString(), {
|
|
852
|
+
nodeName: "pve1",
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
expectRefusedAsNotFound(call);
|
|
856
|
+
// Nothing to run the per-row check on.
|
|
857
|
+
expect(checkUpdatePermissionByModel).not.toHaveBeenCalled();
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test("answers the same NotFound for a cluster the caller may not edit", async () => {
|
|
861
|
+
checkUpdateQueryPermissions.mockRejectedValue(
|
|
862
|
+
new NotAuthorizedException(
|
|
863
|
+
"You do not have permissions to update Proxmox Cluster.",
|
|
864
|
+
),
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
const forbidden: RouteCall = await callRoute(clusterId.toString(), {
|
|
868
|
+
nodeName: "pve1",
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
expectRefusedAsNotFound(forbidden);
|
|
872
|
+
// The permission refusal never falls through to a read.
|
|
873
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
874
|
+
expect(checkUpdatePermissionByModel).not.toHaveBeenCalled();
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("forbidden and missing are indistinguishable to the caller", async () => {
|
|
878
|
+
checkUpdateQueryPermissions.mockRejectedValue(
|
|
879
|
+
new NotAuthorizedException("nope"),
|
|
880
|
+
);
|
|
881
|
+
const forbidden: RouteCall = await callRoute(clusterId.toString(), {
|
|
882
|
+
nodeName: "pve1",
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
jest.clearAllMocks();
|
|
886
|
+
checkUpdateQueryPermissions.mockImplementation(
|
|
887
|
+
async (_modelType: unknown, query: unknown) => {
|
|
888
|
+
return query as never;
|
|
889
|
+
},
|
|
890
|
+
);
|
|
891
|
+
findOneBy.mockResolvedValue(null);
|
|
892
|
+
const missing: RouteCall = await callRoute(
|
|
893
|
+
ObjectID.generate().toString(),
|
|
894
|
+
{ nodeName: "pve1" },
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
expect(forbidden.thrown).toBeInstanceOf(NotFoundException);
|
|
898
|
+
expect(missing.thrown).toBeInstanceOf(NotFoundException);
|
|
899
|
+
expect((forbidden.thrown as NotFoundException).message).toBe(
|
|
900
|
+
(missing.thrown as NotFoundException).message,
|
|
901
|
+
);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("answers NotFound for a caller with no project, without checking anything", async () => {
|
|
905
|
+
props = {
|
|
906
|
+
isRoot: false,
|
|
907
|
+
userId: ObjectID.generate(),
|
|
908
|
+
} as DatabaseCommonInteractionProps;
|
|
909
|
+
|
|
910
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
911
|
+
nodeName: "pve1",
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
expectRefusedAsNotFound(call);
|
|
915
|
+
expect(checkUpdateQueryPermissions).not.toHaveBeenCalled();
|
|
916
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test("answers NotFound when the row came back without a project", async () => {
|
|
920
|
+
const orphan: ProxmoxCluster = new ProxmoxCluster();
|
|
921
|
+
orphan.id = clusterId;
|
|
922
|
+
findOneBy.mockResolvedValue(orphan);
|
|
923
|
+
|
|
924
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
925
|
+
nodeName: "pve1",
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
expectRefusedAsNotFound(call);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("lets an anonymous refusal through as-is so the client can sign in again", async () => {
|
|
932
|
+
const refusal: NotAuthenticatedException = new NotAuthenticatedException(
|
|
933
|
+
"Please sign in",
|
|
934
|
+
);
|
|
935
|
+
checkUpdateQueryPermissions.mockRejectedValue(refusal);
|
|
936
|
+
|
|
937
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
938
|
+
nodeName: "pve1",
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
expect(call.nextCallCount).toBe(1);
|
|
942
|
+
expect(call.thrown).toBe(refusal);
|
|
943
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
944
|
+
expectNothingDone();
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test("passes any other permission failure through unchanged", async () => {
|
|
948
|
+
const failure: BadDataException = new BadDataException(
|
|
949
|
+
"isMultiTenantRequest not allowed on Proxmox Cluster",
|
|
950
|
+
);
|
|
951
|
+
checkUpdateQueryPermissions.mockRejectedValue(failure);
|
|
952
|
+
|
|
953
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
954
|
+
nodeName: "pve1",
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
expect(call.thrown).toBe(failure);
|
|
958
|
+
expectNothingDone();
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test("hands a lookup failure to next() instead of answering", async () => {
|
|
962
|
+
const failure: Error = new Error("postgres is away");
|
|
963
|
+
findOneBy.mockRejectedValue(failure);
|
|
964
|
+
|
|
965
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
966
|
+
nodeName: "pve1",
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
expect(call.nextCallCount).toBe(1);
|
|
970
|
+
expect(call.thrown).toBe(failure);
|
|
971
|
+
expectNothingDone();
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
describe("real update permission check", () => {
|
|
976
|
+
/*
|
|
977
|
+
* The permission check is not stubbed here: what the caller holds in
|
|
978
|
+
* the project decides whether the cluster lookup ever runs. A plain
|
|
979
|
+
* (non-Owned, unlabelled) grant needs no database.
|
|
980
|
+
*/
|
|
981
|
+
beforeEach(() => {
|
|
982
|
+
checkUpdateQueryPermissions.mockRestore();
|
|
983
|
+
checkUpdatePermissionByModel.mockRestore();
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
/*
|
|
987
|
+
* The real check hands the tenant predicate on as a TypeORM operator
|
|
988
|
+
* whose parameter is the project id; a stubbed one hands the ObjectID
|
|
989
|
+
* on as-is. Either way this is the project the lookup is scoped to.
|
|
990
|
+
*/
|
|
991
|
+
function scopedProjectIds(): Array<string> {
|
|
992
|
+
expect(findOneBy).toHaveBeenCalledTimes(1);
|
|
993
|
+
const query: JSONObject = (findOneBy.mock.calls[0]![0] as JSONObject)[
|
|
994
|
+
"query"
|
|
995
|
+
] as JSONObject;
|
|
996
|
+
const value: unknown = query["projectId"];
|
|
997
|
+
|
|
998
|
+
if (value instanceof FindOperator) {
|
|
999
|
+
return Object.values(value.objectLiteralParameters || {}).map(
|
|
1000
|
+
(parameter: unknown) => {
|
|
1001
|
+
return String(parameter);
|
|
1002
|
+
},
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return [String(value)];
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function scopedClusterId(): unknown {
|
|
1010
|
+
return (
|
|
1011
|
+
(findOneBy.mock.calls[0]![0] as JSONObject)["query"] as JSONObject
|
|
1012
|
+
)["_id"];
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/*
|
|
1016
|
+
* The labels a lookup query is narrowed to, or null when it is not.
|
|
1017
|
+
* The real check adds { labels: { _id: <operator over the permitted
|
|
1018
|
+
* ids> } } for a label-scoped grant.
|
|
1019
|
+
*/
|
|
1020
|
+
function permittedLabelIds(query: JSONObject): Array<string> | null {
|
|
1021
|
+
const predicate: unknown = query["labels"];
|
|
1022
|
+
if (predicate === undefined || predicate === null) {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const idFilter: unknown = (predicate as JSONObject)["_id"];
|
|
1027
|
+
if (!(idFilter instanceof FindOperator)) {
|
|
1028
|
+
throw new Error(
|
|
1029
|
+
`Unexpected label predicate: ${JSON.stringify(predicate)}`,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
let ids: Array<string> = [];
|
|
1034
|
+
for (const value of Object.values(
|
|
1035
|
+
idFilter.objectLiteralParameters || {},
|
|
1036
|
+
)) {
|
|
1037
|
+
const values: Array<unknown> = Array.isArray(value) ? value : [value];
|
|
1038
|
+
ids = ids.concat(
|
|
1039
|
+
values.map((id: unknown): string => {
|
|
1040
|
+
return String(id);
|
|
1041
|
+
}),
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
return ids;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function scopedLabelIds(): Array<string> | null {
|
|
1048
|
+
expect(findOneBy).toHaveBeenCalledTimes(1);
|
|
1049
|
+
return permittedLabelIds(
|
|
1050
|
+
(findOneBy.mock.calls[0]![0] as JSONObject)["query"] as JSONObject,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/*
|
|
1055
|
+
* The cluster as the database holds it, served the way each lookup
|
|
1056
|
+
* would serve it. The scoped lookup (findOneBy) applies the label
|
|
1057
|
+
* predicate the real check adds: it finds the row only when one of
|
|
1058
|
+
* its labels is permitted, and labels selected through it come back
|
|
1059
|
+
* narrowed to the permitted ones, as with a filtered join. That
|
|
1060
|
+
* narrowing is what once hid a blocked label from the per-row check,
|
|
1061
|
+
* so it is kept here. The root load (findOneById) has every label.
|
|
1062
|
+
*/
|
|
1063
|
+
function storeCluster(labelIds: Array<ObjectID>): void {
|
|
1064
|
+
findOneBy.mockImplementation(
|
|
1065
|
+
async (args: unknown): Promise<ProxmoxCluster | null> => {
|
|
1066
|
+
const findArgs: JSONObject = args as JSONObject;
|
|
1067
|
+
const query: JSONObject = findArgs["query"] as JSONObject;
|
|
1068
|
+
if (query["_id"] !== clusterId.toString()) {
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const permitted: Array<string> | null = permittedLabelIds(query);
|
|
1073
|
+
const visible: Array<ObjectID> =
|
|
1074
|
+
permitted === null
|
|
1075
|
+
? labelIds
|
|
1076
|
+
: labelIds.filter((labelId: ObjectID): boolean => {
|
|
1077
|
+
return permitted.includes(labelId.toString());
|
|
1078
|
+
});
|
|
1079
|
+
if (permitted !== null && visible.length === 0) {
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const select: JSONObject =
|
|
1084
|
+
(findArgs["select"] as JSONObject | undefined) || {};
|
|
1085
|
+
if (select["labels"]) {
|
|
1086
|
+
return clusterRow(visible);
|
|
1087
|
+
}
|
|
1088
|
+
const row: ProxmoxCluster = new ProxmoxCluster();
|
|
1089
|
+
row.id = clusterId;
|
|
1090
|
+
row.projectId = projectId;
|
|
1091
|
+
return row;
|
|
1092
|
+
},
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
findOneById.mockImplementation(
|
|
1096
|
+
async (args: unknown): Promise<ProxmoxCluster | null> => {
|
|
1097
|
+
const loadArgs: JSONObject = args as JSONObject;
|
|
1098
|
+
if (String(loadArgs["id"]) !== clusterId.toString()) {
|
|
1099
|
+
return null;
|
|
1100
|
+
}
|
|
1101
|
+
return clusterRow(labelIds);
|
|
1102
|
+
},
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function expectLabelsLoadedUnfiltered(): void {
|
|
1107
|
+
// Once per request, however many of the checks asked.
|
|
1108
|
+
expect(findOneById).toHaveBeenCalledTimes(1);
|
|
1109
|
+
for (const loadCall of findOneById.mock.calls) {
|
|
1110
|
+
const loadArgs: JSONObject = loadCall[0] as JSONObject;
|
|
1111
|
+
expect((loadArgs["id"] as ObjectID).toString()).toBe(
|
|
1112
|
+
clusterId.toString(),
|
|
1113
|
+
);
|
|
1114
|
+
expect(loadArgs["select"]).toEqual(UNFILTERED_LOAD_SELECT);
|
|
1115
|
+
expect(loadArgs["props"]).toEqual({ isRoot: true });
|
|
1116
|
+
}
|
|
1117
|
+
// After the scoped lookup found the row, never instead of it.
|
|
1118
|
+
expect(findOneBy.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
1119
|
+
findOneById.mock.invocationCallOrder[0]!,
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/*
|
|
1124
|
+
* Runs the real per-row check, counting how often it asks for the
|
|
1125
|
+
* cluster with its labels: the block check asks under a labelled block
|
|
1126
|
+
* on the edit permission, the allow check under a label-scoped grant.
|
|
1127
|
+
*/
|
|
1128
|
+
function countLabelAsks(): LabelAsks {
|
|
1129
|
+
const asks: LabelAsks = { count: 0 };
|
|
1130
|
+
const realCheck: (data: ByModelCheckInput) => Promise<void> =
|
|
1131
|
+
ModelPermission.checkUpdatePermissionByModel.bind(ModelPermission);
|
|
1132
|
+
|
|
1133
|
+
checkUpdatePermissionByModel = jest.spyOn(
|
|
1134
|
+
ModelPermission,
|
|
1135
|
+
"checkUpdatePermissionByModel",
|
|
1136
|
+
);
|
|
1137
|
+
checkUpdatePermissionByModel.mockImplementation(
|
|
1138
|
+
async (data: ByModelCheckInput): Promise<void> => {
|
|
1139
|
+
return realCheck({
|
|
1140
|
+
...data,
|
|
1141
|
+
fetchModelWithAccessControlIds: (): FetchedForCheck => {
|
|
1142
|
+
asks.count++;
|
|
1143
|
+
return data.fetchModelWithAccessControlIds();
|
|
1144
|
+
},
|
|
1145
|
+
});
|
|
1146
|
+
},
|
|
1147
|
+
);
|
|
1148
|
+
|
|
1149
|
+
return asks;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function propsWith(
|
|
1153
|
+
grants: Array<PermissionGrant>,
|
|
1154
|
+
grantedInProject: ObjectID = projectId,
|
|
1155
|
+
): DatabaseCommonInteractionProps {
|
|
1156
|
+
const permissions: Array<UserPermission> = grants.map(
|
|
1157
|
+
(grant: PermissionGrant): UserPermission => {
|
|
1158
|
+
return {
|
|
1159
|
+
_type: "UserPermission",
|
|
1160
|
+
permission: grant.permission,
|
|
1161
|
+
labelIds: grant.labelIds || [],
|
|
1162
|
+
isBlockPermission: Boolean(grant.isBlockPermission),
|
|
1163
|
+
};
|
|
1164
|
+
},
|
|
1165
|
+
);
|
|
1166
|
+
|
|
1167
|
+
const permissionMap: Dictionary<UserTenantAccessPermission> = {};
|
|
1168
|
+
permissionMap[grantedInProject.toString()] = {
|
|
1169
|
+
_type: "UserTenantAccessPermission",
|
|
1170
|
+
projectId: grantedInProject,
|
|
1171
|
+
permissions: permissions,
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
return {
|
|
1175
|
+
tenantId: projectId,
|
|
1176
|
+
userId: ObjectID.generate(),
|
|
1177
|
+
userType: UserType.User,
|
|
1178
|
+
userTenantAccessPermission: permissionMap,
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
test.each([
|
|
1183
|
+
[[Permission.ProjectOwner]],
|
|
1184
|
+
[[Permission.ProjectAdmin]],
|
|
1185
|
+
[[Permission.ProjectMember]],
|
|
1186
|
+
[[Permission.SettingsAdmin]],
|
|
1187
|
+
[[Permission.SettingsMember]],
|
|
1188
|
+
// A custom role: the dedicated edit permission plus the read it rides on.
|
|
1189
|
+
[[Permission.EditProxmoxCluster, Permission.ReadProxmoxCluster]],
|
|
1190
|
+
] as Array<[Array<Permission>]>)(
|
|
1191
|
+
"a caller holding %p may remove a node",
|
|
1192
|
+
async (permissions: Array<Permission>) => {
|
|
1193
|
+
props = propsWith(
|
|
1194
|
+
permissions.map((permission: Permission): PermissionGrant => {
|
|
1195
|
+
return { permission };
|
|
1196
|
+
}),
|
|
1197
|
+
);
|
|
1198
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
1199
|
+
|
|
1200
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1201
|
+
nodeName: "pve1",
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
expect(call.nextCallCount).toBe(0);
|
|
1205
|
+
expect(scopedClusterId()).toBe(clusterId.toString());
|
|
1206
|
+
expect(scopedProjectIds()).toEqual([projectId.toString()]);
|
|
1207
|
+
expect((findOneBy.mock.calls[0]![0] as JSONObject)["props"]).toEqual({
|
|
1208
|
+
isRoot: true,
|
|
1209
|
+
});
|
|
1210
|
+
expect(removeArgs()["externalId"]).toBe("node/pve1");
|
|
1211
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
1212
|
+
},
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
test("EditProxmoxCluster without any read grant is refused, as the CRUD update would be", async () => {
|
|
1216
|
+
/*
|
|
1217
|
+
* The update check also asks for read access to the columns the
|
|
1218
|
+
* lookup filters on (projectId), exactly as it does for the generic
|
|
1219
|
+
* update endpoint, so an edit-only role cannot use either.
|
|
1220
|
+
*/
|
|
1221
|
+
props = propsWith([{ permission: Permission.EditProxmoxCluster }]);
|
|
1222
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
1223
|
+
|
|
1224
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1225
|
+
nodeName: "pve1",
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
expectRefusedAsNotFound(call);
|
|
1229
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test.each([
|
|
1233
|
+
Permission.ReadProxmoxCluster,
|
|
1234
|
+
Permission.Viewer,
|
|
1235
|
+
Permission.SettingsViewer,
|
|
1236
|
+
Permission.DeleteProxmoxCluster,
|
|
1237
|
+
Permission.CreateProxmoxCluster,
|
|
1238
|
+
])(
|
|
1239
|
+
"a caller holding only %s gets NotFound and nothing is read",
|
|
1240
|
+
async (permission: Permission) => {
|
|
1241
|
+
props = propsWith([{ permission }]);
|
|
1242
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
1243
|
+
|
|
1244
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1245
|
+
nodeName: "pve1",
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
expectRefusedAsNotFound(call);
|
|
1249
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
1250
|
+
},
|
|
1251
|
+
);
|
|
1252
|
+
|
|
1253
|
+
test("a caller with no permissions at all gets NotFound", async () => {
|
|
1254
|
+
props = propsWith([]);
|
|
1255
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
1256
|
+
|
|
1257
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1258
|
+
nodeName: "pve1",
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
expectRefusedAsNotFound(call);
|
|
1262
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
test("an unlabelled block on the edit permission overrides the grant", async () => {
|
|
1266
|
+
props = propsWith([
|
|
1267
|
+
{ permission: Permission.EditProxmoxCluster },
|
|
1268
|
+
{ permission: Permission.ReadProxmoxCluster },
|
|
1269
|
+
{ permission: Permission.EditProxmoxCluster, isBlockPermission: true },
|
|
1270
|
+
]);
|
|
1271
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
1272
|
+
|
|
1273
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1274
|
+
nodeName: "pve1",
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
expectRefusedAsNotFound(call);
|
|
1278
|
+
expect(removeOfflineNode).not.toHaveBeenCalled();
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
test("a block on the edit permission for one of the cluster's labels refuses it", async () => {
|
|
1282
|
+
const blockedLabel: ObjectID = ObjectID.generate();
|
|
1283
|
+
props = propsWith([
|
|
1284
|
+
{ permission: Permission.ProjectMember },
|
|
1285
|
+
{
|
|
1286
|
+
permission: Permission.EditProxmoxCluster,
|
|
1287
|
+
isBlockPermission: true,
|
|
1288
|
+
labelIds: [blockedLabel],
|
|
1289
|
+
},
|
|
1290
|
+
]);
|
|
1291
|
+
storeCluster([ObjectID.generate(), blockedLabel]);
|
|
1292
|
+
|
|
1293
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1294
|
+
nodeName: "pve1",
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
expectRefusedAsNotFound(call);
|
|
1298
|
+
// A project-wide grant: the lookup is not label-scoped.
|
|
1299
|
+
expect(scopedLabelIds()).toBeNull();
|
|
1300
|
+
expectLabelsLoadedUnfiltered();
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test("a block for a label the cluster does not carry leaves it editable", async () => {
|
|
1304
|
+
props = propsWith([
|
|
1305
|
+
{ permission: Permission.ProjectMember },
|
|
1306
|
+
{
|
|
1307
|
+
permission: Permission.EditProxmoxCluster,
|
|
1308
|
+
isBlockPermission: true,
|
|
1309
|
+
labelIds: [ObjectID.generate()],
|
|
1310
|
+
},
|
|
1311
|
+
]);
|
|
1312
|
+
storeCluster([ObjectID.generate()]);
|
|
1313
|
+
|
|
1314
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1315
|
+
nodeName: "pve1",
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
expect(call.nextCallCount).toBe(0);
|
|
1319
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
1320
|
+
expectLabelsLoadedUnfiltered();
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
describe("a label-scoped edit grant and a team block on another label of the same cluster", () => {
|
|
1324
|
+
/*
|
|
1325
|
+
* The caller may edit (and read) clusters labelled A; the caller's
|
|
1326
|
+
* team blocks editing clusters labelled B. A cluster carrying both
|
|
1327
|
+
* is found by the label-A-scoped lookup, so only the per-row block
|
|
1328
|
+
* check can refuse it, and only if it sees label B. Labels loaded
|
|
1329
|
+
* through the scoped lookup would carry A alone.
|
|
1330
|
+
*/
|
|
1331
|
+
let labelA: ObjectID;
|
|
1332
|
+
let labelB: ObjectID;
|
|
1333
|
+
|
|
1334
|
+
beforeEach(() => {
|
|
1335
|
+
labelA = ObjectID.generate();
|
|
1336
|
+
labelB = ObjectID.generate();
|
|
1337
|
+
props = propsWith([
|
|
1338
|
+
{ permission: Permission.EditProxmoxCluster, labelIds: [labelA] },
|
|
1339
|
+
{ permission: Permission.ReadProxmoxCluster, labelIds: [labelA] },
|
|
1340
|
+
{
|
|
1341
|
+
permission: Permission.EditProxmoxCluster,
|
|
1342
|
+
isBlockPermission: true,
|
|
1343
|
+
labelIds: [labelB],
|
|
1344
|
+
},
|
|
1345
|
+
]);
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
test("a cluster carrying A and B is refused with the same NotFound, and nothing is removed", async () => {
|
|
1349
|
+
storeCluster([labelA, labelB]);
|
|
1350
|
+
|
|
1351
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1352
|
+
nodeName: "pve1",
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
expectRefusedAsNotFound(call);
|
|
1356
|
+
expect((call.thrown as Exception).code).toBe(404);
|
|
1357
|
+
expect(removeOfflineNode).not.toHaveBeenCalled();
|
|
1358
|
+
|
|
1359
|
+
// The lookup really was narrowed to label A...
|
|
1360
|
+
expect(scopedLabelIds()).toEqual([labelA.toString()]);
|
|
1361
|
+
expect(scopedClusterId()).toBe(clusterId.toString());
|
|
1362
|
+
expect(scopedProjectIds()).toEqual([projectId.toString()]);
|
|
1363
|
+
// ...so it carries no labels for the block check to trust...
|
|
1364
|
+
expect((findOneBy.mock.calls[0]![0] as JSONObject)["select"]).toEqual({
|
|
1365
|
+
_id: true,
|
|
1366
|
+
projectId: true,
|
|
1367
|
+
});
|
|
1368
|
+
// ...which loads the cluster again, as root, with every label.
|
|
1369
|
+
expectLabelsLoadedUnfiltered();
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
test("the order of the labels on the cluster does not matter", async () => {
|
|
1373
|
+
storeCluster([labelB, labelA]);
|
|
1374
|
+
|
|
1375
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1376
|
+
nodeName: "pve1",
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
expectRefusedAsNotFound(call);
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
test("a cluster carrying only A may have a node removed", async () => {
|
|
1383
|
+
storeCluster([labelA]);
|
|
1384
|
+
|
|
1385
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1386
|
+
nodeName: "pve1",
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
expect(call.nextCallCount).toBe(0);
|
|
1390
|
+
expect(scopedLabelIds()).toEqual([labelA.toString()]);
|
|
1391
|
+
expectLabelsLoadedUnfiltered();
|
|
1392
|
+
expect(removeArgs()["externalId"]).toBe("node/pve1");
|
|
1393
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test("a cluster carrying A and an unblocked label C may have a node removed", async () => {
|
|
1397
|
+
storeCluster([labelA, ObjectID.generate()]);
|
|
1398
|
+
|
|
1399
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1400
|
+
nodeName: "pve1",
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
expect(call.nextCallCount).toBe(0);
|
|
1404
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("a cluster carrying only B is never found, so nothing is loaded or removed", async () => {
|
|
1408
|
+
storeCluster([labelB]);
|
|
1409
|
+
|
|
1410
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1411
|
+
nodeName: "pve1",
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
expectRefusedAsNotFound(call);
|
|
1415
|
+
expect(findOneById).not.toHaveBeenCalled();
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
test("without the block the same caller may remove a node on a cluster carrying A and B", async () => {
|
|
1419
|
+
props = propsWith([
|
|
1420
|
+
{ permission: Permission.EditProxmoxCluster, labelIds: [labelA] },
|
|
1421
|
+
{ permission: Permission.ReadProxmoxCluster, labelIds: [labelA] },
|
|
1422
|
+
]);
|
|
1423
|
+
storeCluster([labelA, labelB]);
|
|
1424
|
+
|
|
1425
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1426
|
+
nodeName: "pve1",
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
expect(call.nextCallCount).toBe(0);
|
|
1430
|
+
expectLabelsLoadedUnfiltered();
|
|
1431
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
test.each([
|
|
1435
|
+
["only A", false],
|
|
1436
|
+
["A and an unblocked label C", true],
|
|
1437
|
+
] as Array<[string, boolean]>)(
|
|
1438
|
+
"on a cluster carrying %s both checks ask for its labels, and it is loaded once",
|
|
1439
|
+
async (_label: string, withUnblockedLabel: boolean) => {
|
|
1440
|
+
storeCluster(
|
|
1441
|
+
withUnblockedLabel ? [labelA, ObjectID.generate()] : [labelA],
|
|
1442
|
+
);
|
|
1443
|
+
const asks: LabelAsks = countLabelAsks();
|
|
1444
|
+
|
|
1445
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1446
|
+
nodeName: "pve1",
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
expect(call.nextCallCount).toBe(0);
|
|
1450
|
+
// The block check (B is not on it) and then the allow check (A is).
|
|
1451
|
+
expect(asks.count).toBe(2);
|
|
1452
|
+
expect(findOneById).toHaveBeenCalledTimes(1);
|
|
1453
|
+
expectLabelsLoadedUnfiltered();
|
|
1454
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
1455
|
+
},
|
|
1456
|
+
);
|
|
1457
|
+
|
|
1458
|
+
test("on a cluster carrying A and B the block check refuses on its first ask, from the one load", async () => {
|
|
1459
|
+
storeCluster([labelA, labelB]);
|
|
1460
|
+
const asks: LabelAsks = countLabelAsks();
|
|
1461
|
+
|
|
1462
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1463
|
+
nodeName: "pve1",
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
expectRefusedAsNotFound(call);
|
|
1467
|
+
expect(asks.count).toBe(1);
|
|
1468
|
+
expect(findOneById).toHaveBeenCalledTimes(1);
|
|
1469
|
+
expectLabelsLoadedUnfiltered();
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
test("a cluster deleted between the scoped lookup and the labels load answers 404 with the editable-cluster message", async () => {
|
|
1473
|
+
storeCluster([labelA]);
|
|
1474
|
+
// Found through the label-A-scoped lookup; gone when loaded as root.
|
|
1475
|
+
findOneById.mockResolvedValue(null);
|
|
1476
|
+
const asks: LabelAsks = countLabelAsks();
|
|
1477
|
+
|
|
1478
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1479
|
+
nodeName: "pve1",
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
/*
|
|
1483
|
+
* Not the real check's own "ProxmoxCluster not found." (a 400),
|
|
1484
|
+
* which would tell a missing cluster apart from a forbidden one.
|
|
1485
|
+
*/
|
|
1486
|
+
expectRefusedAsNotFound(call);
|
|
1487
|
+
expect((call.thrown as Exception).code).toBe(404);
|
|
1488
|
+
expect(call.thrown).not.toBeInstanceOf(BadDataException);
|
|
1489
|
+
expect(scopedLabelIds()).toEqual([labelA.toString()]);
|
|
1490
|
+
// The block check asked first and got the refusal; nothing asked again.
|
|
1491
|
+
expect(asks.count).toBe(1);
|
|
1492
|
+
expectLabelsLoadedUnfiltered();
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
test("the labels are loaded afresh for every request: a block label added after one removal refuses the next", async () => {
|
|
1496
|
+
storeCluster([labelA]);
|
|
1497
|
+
const asks: LabelAsks = countLabelAsks();
|
|
1498
|
+
|
|
1499
|
+
const first: RouteCall = await callRoute(clusterId.toString(), {
|
|
1500
|
+
nodeName: "pve1",
|
|
1501
|
+
});
|
|
1502
|
+
expect(first.nextCallCount).toBe(0);
|
|
1503
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
1504
|
+
|
|
1505
|
+
// Label B is put on the cluster before the next request.
|
|
1506
|
+
storeCluster([labelA, labelB]);
|
|
1507
|
+
|
|
1508
|
+
const second: RouteCall = await callRoute(clusterId.toString(), {
|
|
1509
|
+
nodeName: "pve2",
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
expect(second.nextCallCount).toBe(1);
|
|
1513
|
+
expect(second.thrown).toBeInstanceOf(NotFoundException);
|
|
1514
|
+
expect((second.thrown as NotFoundException).message).toBe(
|
|
1515
|
+
NOT_FOUND_MESSAGE,
|
|
1516
|
+
);
|
|
1517
|
+
// One load per request; the first request's load is not reused.
|
|
1518
|
+
expect(findOneById).toHaveBeenCalledTimes(2);
|
|
1519
|
+
expect(asks.count).toBe(3);
|
|
1520
|
+
// Only the first request removed anything.
|
|
1521
|
+
expect(removeOfflineNode).toHaveBeenCalledTimes(1);
|
|
1522
|
+
expect(
|
|
1523
|
+
(removeOfflineNode.mock.calls[0]![0] as JSONObject)["externalId"],
|
|
1524
|
+
).toBe("node/pve1");
|
|
1525
|
+
expect(Response.sendJsonObjectResponse).toHaveBeenCalledTimes(1);
|
|
1526
|
+
});
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
test.each([
|
|
1530
|
+
["a label-scoped grant alone (only the allow check asks)", false, true],
|
|
1531
|
+
[
|
|
1532
|
+
"a project-wide grant and a labelled block (only the block check asks)",
|
|
1533
|
+
true,
|
|
1534
|
+
false,
|
|
1535
|
+
],
|
|
1536
|
+
] as Array<[string, boolean, boolean]>)(
|
|
1537
|
+
"a cluster deleted before the labels load answers the same 404 under %s",
|
|
1538
|
+
async (_label: string, withBlock: boolean, labelScoped: boolean) => {
|
|
1539
|
+
const labelA: ObjectID = ObjectID.generate();
|
|
1540
|
+
const grants: Array<PermissionGrant> = labelScoped
|
|
1541
|
+
? [
|
|
1542
|
+
{ permission: Permission.EditProxmoxCluster, labelIds: [labelA] },
|
|
1543
|
+
{ permission: Permission.ReadProxmoxCluster, labelIds: [labelA] },
|
|
1544
|
+
]
|
|
1545
|
+
: [{ permission: Permission.ProjectMember }];
|
|
1546
|
+
if (withBlock) {
|
|
1547
|
+
grants.push({
|
|
1548
|
+
permission: Permission.EditProxmoxCluster,
|
|
1549
|
+
isBlockPermission: true,
|
|
1550
|
+
labelIds: [ObjectID.generate()],
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
props = propsWith(grants);
|
|
1554
|
+
storeCluster([labelA]);
|
|
1555
|
+
findOneById.mockResolvedValue(null);
|
|
1556
|
+
const asks: LabelAsks = countLabelAsks();
|
|
1557
|
+
|
|
1558
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1559
|
+
nodeName: "pve1",
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
expectRefusedAsNotFound(call);
|
|
1563
|
+
expect((call.thrown as Exception).code).toBe(404);
|
|
1564
|
+
expect(asks.count).toBe(1);
|
|
1565
|
+
expectLabelsLoadedUnfiltered();
|
|
1566
|
+
},
|
|
1567
|
+
);
|
|
1568
|
+
|
|
1569
|
+
test("an edit grant in another project does not count in this one", async () => {
|
|
1570
|
+
props = propsWith(
|
|
1571
|
+
[
|
|
1572
|
+
{ permission: Permission.EditProxmoxCluster },
|
|
1573
|
+
{ permission: Permission.ReadProxmoxCluster },
|
|
1574
|
+
{ permission: Permission.ProjectOwner },
|
|
1575
|
+
],
|
|
1576
|
+
ObjectID.generate(),
|
|
1577
|
+
);
|
|
1578
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
1579
|
+
|
|
1580
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1581
|
+
nodeName: "pve1",
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
expectRefusedAsNotFound(call);
|
|
1585
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
test("a master admin passes the check and the lookup stays scoped to the request's project", async () => {
|
|
1589
|
+
props = {
|
|
1590
|
+
tenantId: projectId,
|
|
1591
|
+
userId: ObjectID.generate(),
|
|
1592
|
+
userType: UserType.MasterAdmin,
|
|
1593
|
+
isMasterAdmin: true,
|
|
1594
|
+
};
|
|
1595
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
1596
|
+
|
|
1597
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1598
|
+
nodeName: "pve1",
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
expect(call.nextCallCount).toBe(0);
|
|
1602
|
+
expect(scopedClusterId()).toBe(clusterId.toString());
|
|
1603
|
+
expect(scopedProjectIds()).toEqual([projectId.toString()]);
|
|
1604
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
test("a master admin without a project on the request gets NotFound", async () => {
|
|
1608
|
+
props = {
|
|
1609
|
+
userId: ObjectID.generate(),
|
|
1610
|
+
userType: UserType.MasterAdmin,
|
|
1611
|
+
isMasterAdmin: true,
|
|
1612
|
+
};
|
|
1613
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
1614
|
+
|
|
1615
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1616
|
+
nodeName: "pve1",
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
/*
|
|
1620
|
+
* Without a project the lookup would not be scoped to one at all
|
|
1621
|
+
* (the master-admin check adds no tenant predicate), so it never runs.
|
|
1622
|
+
*/
|
|
1623
|
+
expectRefusedAsNotFound(call);
|
|
1624
|
+
expect(findOneBy).not.toHaveBeenCalled();
|
|
1625
|
+
});
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
describe("removal", () => {
|
|
1629
|
+
beforeEach(() => {
|
|
1630
|
+
findOneBy.mockResolvedValue(clusterRow());
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
test("removes node/<name> in the cluster's own project and cluster", async () => {
|
|
1634
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1635
|
+
nodeName: "pve1",
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
expect(call.nextCallCount).toBe(0);
|
|
1639
|
+
const args: JSONObject = removeArgs();
|
|
1640
|
+
expect(Object.keys(args).sort()).toEqual([
|
|
1641
|
+
"externalId",
|
|
1642
|
+
"projectId",
|
|
1643
|
+
"proxmoxClusterId",
|
|
1644
|
+
]);
|
|
1645
|
+
expect(args["externalId"]).toBe("node/pve1");
|
|
1646
|
+
expect((args["projectId"] as ObjectID).toString()).toBe(
|
|
1647
|
+
projectId.toString(),
|
|
1648
|
+
);
|
|
1649
|
+
expect((args["proxmoxClusterId"] as ObjectID).toString()).toBe(
|
|
1650
|
+
clusterId.toString(),
|
|
1651
|
+
);
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
test("answers 200 with removed: true when the service removed the node", async () => {
|
|
1655
|
+
removeOfflineNode.mockResolvedValue(REMOVED);
|
|
1656
|
+
|
|
1657
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1658
|
+
nodeName: "pve1",
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
expect(call.nextCallCount).toBe(0);
|
|
1662
|
+
expect(sentBody()).toEqual({ removed: true });
|
|
1663
|
+
const responseCall: Array<unknown> = (
|
|
1664
|
+
Response.sendJsonObjectResponse as jest.Mock
|
|
1665
|
+
).mock.calls[0] as Array<unknown>;
|
|
1666
|
+
expect(responseCall[1]).toBe(mockResponse);
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
test.each([
|
|
1670
|
+
[
|
|
1671
|
+
"not-found",
|
|
1672
|
+
"404: the node is not in the inventory (it may already be gone)",
|
|
1673
|
+
NotFoundException,
|
|
1674
|
+
404,
|
|
1675
|
+
NODE_NOT_FOUND_MESSAGE,
|
|
1676
|
+
],
|
|
1677
|
+
[
|
|
1678
|
+
"not-native",
|
|
1679
|
+
"400: an agent node, which leaves on its own",
|
|
1680
|
+
BadDataException,
|
|
1681
|
+
400,
|
|
1682
|
+
NOT_NATIVE_MESSAGE,
|
|
1683
|
+
],
|
|
1684
|
+
[
|
|
1685
|
+
"still-reporting",
|
|
1686
|
+
"400: the node is still up and would come back",
|
|
1687
|
+
BadDataException,
|
|
1688
|
+
400,
|
|
1689
|
+
STILL_REPORTING_MESSAGE,
|
|
1690
|
+
],
|
|
1691
|
+
] as Array<
|
|
1692
|
+
[
|
|
1693
|
+
ProxmoxRemoveNodeResult,
|
|
1694
|
+
string,
|
|
1695
|
+
new (message: string) => Exception,
|
|
1696
|
+
number,
|
|
1697
|
+
string,
|
|
1698
|
+
]
|
|
1699
|
+
>)(
|
|
1700
|
+
"answers %s as %s",
|
|
1701
|
+
async (
|
|
1702
|
+
result: ProxmoxRemoveNodeResult,
|
|
1703
|
+
_meaning: string,
|
|
1704
|
+
exceptionType: new (message: string) => Exception,
|
|
1705
|
+
status: number,
|
|
1706
|
+
message: string,
|
|
1707
|
+
) => {
|
|
1708
|
+
removeOfflineNode.mockResolvedValue(result);
|
|
1709
|
+
|
|
1710
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1711
|
+
nodeName: "pve1",
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
expect(call.nextCallCount).toBe(1);
|
|
1715
|
+
expect(call.thrown).toBeInstanceOf(exceptionType);
|
|
1716
|
+
expect((call.thrown as Exception).code).toBe(status);
|
|
1717
|
+
expect((call.thrown as Exception).message).toBe(message);
|
|
1718
|
+
expect(removeOfflineNode).toHaveBeenCalledTimes(1);
|
|
1719
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
1720
|
+
},
|
|
1721
|
+
);
|
|
1722
|
+
|
|
1723
|
+
test.each([
|
|
1724
|
+
["a boolean true from a stale mock of the old boolean contract", true],
|
|
1725
|
+
["a boolean false", false],
|
|
1726
|
+
["an unknown string", "deleted"],
|
|
1727
|
+
["an empty string", ""],
|
|
1728
|
+
["the right word in the wrong case", "Removed"],
|
|
1729
|
+
["the right word padded", " removed "],
|
|
1730
|
+
["a known reason in the wrong case", "Not-Found"],
|
|
1731
|
+
["a number", 1],
|
|
1732
|
+
["null", null],
|
|
1733
|
+
["undefined", undefined],
|
|
1734
|
+
["an object claiming success", { removed: true }],
|
|
1735
|
+
["an array holding the right word", ["removed"]],
|
|
1736
|
+
] as Array<[string, unknown]>)(
|
|
1737
|
+
"answers 400, never 200, when the service returns %s",
|
|
1738
|
+
async (_label: string, unexpected: unknown) => {
|
|
1739
|
+
removeOfflineNode.mockResolvedValue(unexpected);
|
|
1740
|
+
|
|
1741
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1742
|
+
nodeName: "pve1",
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
expect(removeOfflineNode).toHaveBeenCalledTimes(1);
|
|
1746
|
+
expect(call.nextCallCount).toBe(1);
|
|
1747
|
+
expect(call.thrown).toBeInstanceOf(BadDataException);
|
|
1748
|
+
expect((call.thrown as Exception).code).toBe(400);
|
|
1749
|
+
// The conservative explanation: not claimed removed, not claimed missing.
|
|
1750
|
+
expect((call.thrown as Exception).message).toBe(
|
|
1751
|
+
STILL_REPORTING_MESSAGE,
|
|
1752
|
+
);
|
|
1753
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
1754
|
+
expect(Response.sendEmptySuccessResponse).not.toHaveBeenCalled();
|
|
1755
|
+
expect(Response.sendEntityResponse).not.toHaveBeenCalled();
|
|
1756
|
+
},
|
|
1757
|
+
);
|
|
1758
|
+
|
|
1759
|
+
test('only the exact result "removed" answers 200', async () => {
|
|
1760
|
+
const results: Array<unknown> = [
|
|
1761
|
+
true,
|
|
1762
|
+
"Removed",
|
|
1763
|
+
"removed",
|
|
1764
|
+
"not-found",
|
|
1765
|
+
"not-native",
|
|
1766
|
+
"still-reporting",
|
|
1767
|
+
"unknown",
|
|
1768
|
+
];
|
|
1769
|
+
const answeredOk: Array<unknown> = [];
|
|
1770
|
+
|
|
1771
|
+
for (const result of results) {
|
|
1772
|
+
jest.clearAllMocks();
|
|
1773
|
+
removeOfflineNode.mockResolvedValue(result);
|
|
1774
|
+
|
|
1775
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1776
|
+
nodeName: "pve1",
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
if (call.nextCallCount === 0) {
|
|
1780
|
+
answeredOk.push(result);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
expect(answeredOk).toEqual(["removed"]);
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
test("never touches the inventory summary", async () => {
|
|
1788
|
+
await callRoute(clusterId.toString(), { nodeName: "pve1" });
|
|
1789
|
+
|
|
1790
|
+
expect(getInventorySummary).not.toHaveBeenCalled();
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
test("hands a service failure to next() instead of answering", async () => {
|
|
1794
|
+
const failure: Error = new Error("postgres is away");
|
|
1795
|
+
removeOfflineNode.mockRejectedValue(failure);
|
|
1796
|
+
|
|
1797
|
+
const call: RouteCall = await callRoute(clusterId.toString(), {
|
|
1798
|
+
nodeName: "pve1",
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
expect(call.nextCallCount).toBe(1);
|
|
1802
|
+
expect(call.thrown).toBe(failure);
|
|
1803
|
+
expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
test("removes only the named node, once per request", async () => {
|
|
1807
|
+
await callRoute(clusterId.toString(), { nodeName: "pve1" });
|
|
1808
|
+
await callRoute(clusterId.toString(), { nodeName: "pve2" });
|
|
1809
|
+
|
|
1810
|
+
expect(removeOfflineNode).toHaveBeenCalledTimes(2);
|
|
1811
|
+
expect(
|
|
1812
|
+
(removeOfflineNode.mock.calls[0]![0] as JSONObject)["externalId"],
|
|
1813
|
+
).toBe("node/pve1");
|
|
1814
|
+
expect(
|
|
1815
|
+
(removeOfflineNode.mock.calls[1]![0] as JSONObject)["externalId"],
|
|
1816
|
+
).toBe("node/pve2");
|
|
1817
|
+
});
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
describe("inventory-summary, next to it, is unchanged", () => {
|
|
1821
|
+
/*
|
|
1822
|
+
* The two routes share the cluster id parsing. The summary still only
|
|
1823
|
+
* needs READ access to the cluster, looked up with the caller's props.
|
|
1824
|
+
*/
|
|
1825
|
+
const SUMMARY: ProxmoxInventorySummary = {
|
|
1826
|
+
countsByKind: { Node: 3, Guest: 12, Storage: 4 },
|
|
1827
|
+
nodeOnlineCount: 2,
|
|
1828
|
+
guestRunningCount: 9,
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
async function callSummary(
|
|
1832
|
+
clusterIdParam: string | undefined,
|
|
1833
|
+
): Promise<RouteCall> {
|
|
1834
|
+
await mockRouter
|
|
1835
|
+
.match("post", INVENTORY_SUMMARY_ROUTE)
|
|
1836
|
+
.handlerFunction(
|
|
1837
|
+
buildRequest(clusterIdParam, {}),
|
|
1838
|
+
mockResponse,
|
|
1839
|
+
nextFunction,
|
|
1840
|
+
);
|
|
1841
|
+
|
|
1842
|
+
const calls: Array<Array<unknown>> = (nextFunction as jest.Mock).mock
|
|
1843
|
+
.calls as Array<Array<unknown>>;
|
|
1844
|
+
|
|
1845
|
+
return {
|
|
1846
|
+
thrown: calls[0]?.[0],
|
|
1847
|
+
nextCallCount: calls.length,
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
test("still rejects a missing id", async () => {
|
|
1852
|
+
const call: RouteCall = await callSummary(undefined);
|
|
1853
|
+
|
|
1854
|
+
expect(call.thrown).toBeInstanceOf(BadDataException);
|
|
1855
|
+
expect((call.thrown as BadDataException).message).toBe(
|
|
1856
|
+
"Cluster ID is required",
|
|
1857
|
+
);
|
|
1858
|
+
expect(findOneById).not.toHaveBeenCalled();
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
test("still resolves the cluster with the caller's props and answers NotFound when unreadable", async () => {
|
|
1862
|
+
findOneById.mockResolvedValue(null);
|
|
1863
|
+
|
|
1864
|
+
const call: RouteCall = await callSummary(clusterId.toString());
|
|
1865
|
+
|
|
1866
|
+
expect(call.thrown).toBeInstanceOf(NotFoundException);
|
|
1867
|
+
expect((call.thrown as NotFoundException).message).toBe(
|
|
1868
|
+
"Proxmox Cluster not found",
|
|
1869
|
+
);
|
|
1870
|
+
const findArgs: JSONObject = findOneById.mock.calls[0]![0] as JSONObject;
|
|
1871
|
+
expect((findArgs["id"] as ObjectID).toString()).toBe(
|
|
1872
|
+
clusterId.toString(),
|
|
1873
|
+
);
|
|
1874
|
+
expect(findArgs["props"]).toBe(props);
|
|
1875
|
+
// The summary needs read access only, never the update check.
|
|
1876
|
+
expect(checkUpdateQueryPermissions).not.toHaveBeenCalled();
|
|
1877
|
+
expect(getInventorySummary).not.toHaveBeenCalled();
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
test("still returns the counts plus the convenience fields", async () => {
|
|
1881
|
+
findOneById.mockResolvedValue(clusterRow());
|
|
1882
|
+
getInventorySummary.mockResolvedValue(SUMMARY);
|
|
1883
|
+
|
|
1884
|
+
const call: RouteCall = await callSummary(clusterId.toString());
|
|
1885
|
+
|
|
1886
|
+
expect(call.nextCallCount).toBe(0);
|
|
1887
|
+
expect(sentBody()).toEqual({
|
|
1888
|
+
countsByKind: { Node: 3, Guest: 12, Storage: 4 },
|
|
1889
|
+
nodeCount: 3,
|
|
1890
|
+
guestCount: 12,
|
|
1891
|
+
storageCount: 4,
|
|
1892
|
+
nodeOnlineCount: 2,
|
|
1893
|
+
guestRunningCount: 9,
|
|
1894
|
+
});
|
|
1895
|
+
expect(removeOfflineNode).not.toHaveBeenCalled();
|
|
1896
|
+
});
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
describe("App/FeatureSet/BaseAPI/Index.ts", () => {
|
|
1900
|
+
/*
|
|
1901
|
+
* Common tests cannot import App, so the mount is checked as text, the
|
|
1902
|
+
* same way VMwareResourceAPI.test.ts pins its own registration.
|
|
1903
|
+
*/
|
|
1904
|
+
const indexPath: string = path.join(
|
|
1905
|
+
__dirname,
|
|
1906
|
+
"..",
|
|
1907
|
+
"..",
|
|
1908
|
+
"..",
|
|
1909
|
+
"..",
|
|
1910
|
+
"App",
|
|
1911
|
+
"FeatureSet",
|
|
1912
|
+
"BaseAPI",
|
|
1913
|
+
"Index.ts",
|
|
1914
|
+
);
|
|
1915
|
+
|
|
1916
|
+
const source: string = fs.readFileSync(indexPath, "utf8");
|
|
1917
|
+
|
|
1918
|
+
test("mounts ProxmoxResourceAPI exactly once, so remove-node is reachable", () => {
|
|
1919
|
+
expect(source).toContain(
|
|
1920
|
+
'import ProxmoxResourceAPI from "Common/Server/API/ProxmoxResourceAPI";',
|
|
1921
|
+
);
|
|
1922
|
+
|
|
1923
|
+
const mounts: Array<string> =
|
|
1924
|
+
source.match(/new ProxmoxResourceAPI\(\)\.getRouter\(\)/g) || [];
|
|
1925
|
+
expect(mounts).toHaveLength(1);
|
|
1926
|
+
|
|
1927
|
+
expect(source).toMatch(
|
|
1928
|
+
/app\.use\(\s*`\/\$\{APP_NAME\.toLocaleLowerCase\(\)\}`,\s*new ProxmoxResourceAPI\(\)\.getRouter\(\),?\s*\)/,
|
|
1929
|
+
);
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1932
|
+
});
|