mirror of
https://github.com/apache/superset.git
synced 2026-06-10 01:59:17 +00:00
Compare commits
7 Commits
fix/extens
...
url-param-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
376b5c9491 | ||
|
|
8eda626466 | ||
|
|
fe9818226d | ||
|
|
1e8438a478 | ||
|
|
8fdabc44f5 | ||
|
|
e9e9245112 | ||
|
|
580be2cf32 |
@@ -226,7 +226,7 @@ def copy_frontend_dist(cwd: Path) -> str:
|
||||
def copy_backend_files(cwd: Path) -> None:
|
||||
"""Copy backend files based on pyproject.toml build configuration (validation already passed)."""
|
||||
dist_dir = cwd / "dist"
|
||||
backend_dir = cwd / "backend"
|
||||
backend_dir = (cwd / "backend").resolve()
|
||||
|
||||
# Read build config from pyproject.toml
|
||||
pyproject = read_toml(backend_dir / "pyproject.toml")
|
||||
@@ -239,11 +239,31 @@ def copy_backend_files(cwd: Path) -> None:
|
||||
|
||||
# Process include patterns
|
||||
for pattern in include_patterns:
|
||||
# Include patterns are only meant to select files within the backend
|
||||
# directory. Reject absolute patterns or ones that walk outside it via
|
||||
# parent ("..") components before handing them to glob().
|
||||
pattern_parts = Path(pattern).parts
|
||||
if Path(pattern).is_absolute() or ".." in pattern_parts:
|
||||
raise click.ClickException(
|
||||
f"Invalid include pattern {pattern!r}: patterns must be "
|
||||
"relative to the backend directory and may not contain '..'."
|
||||
)
|
||||
for f in backend_dir.glob(pattern):
|
||||
if not f.is_file():
|
||||
continue
|
||||
|
||||
# Check exclude patterns
|
||||
# Defense in depth: confirm the matched file resolves to a location
|
||||
# inside the backend directory before copying it into the bundle.
|
||||
resolved = f.resolve()
|
||||
if not resolved.is_relative_to(backend_dir):
|
||||
raise click.ClickException(
|
||||
f"Refusing to copy {f}: resolved path is outside the "
|
||||
f"backend directory {backend_dir}."
|
||||
)
|
||||
|
||||
# Use the matched path (not the resolved target) for the bundle
|
||||
# layout and exclude evaluation so symlinked files are staged at
|
||||
# their configured path rather than their symlink target.
|
||||
relative_path = f.relative_to(backend_dir)
|
||||
should_exclude = any(
|
||||
relative_path.match(excl_pattern) for excl_pattern in exclude_patterns
|
||||
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from superset_extensions_cli.cli import (
|
||||
app,
|
||||
@@ -625,6 +626,155 @@ exclude = []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_supports_legitimate_nested_patterns(isolated_filesystem):
|
||||
"""Test copy_backend_files copies deeply nested files via recursive globs."""
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
nested = backend_dir / "src" / "test_org" / "test_ext" / "deep" / "deeper"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "module.py").write_text("# nested module")
|
||||
|
||||
pyproject_content = """[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"src/test_org/test_ext/**/*.py",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
assert_file_exists(
|
||||
dist_dir
|
||||
/ "backend"
|
||||
/ "src"
|
||||
/ "test_org"
|
||||
/ "test_ext"
|
||||
/ "deep"
|
||||
/ "deeper"
|
||||
/ "module.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"bad_pattern",
|
||||
[
|
||||
"../../.ssh/*",
|
||||
"../config",
|
||||
"src/../../secret.txt",
|
||||
"/etc/passwd",
|
||||
],
|
||||
)
|
||||
def test_copy_backend_files_rejects_patterns_escaping_backend_dir(
|
||||
isolated_filesystem, bad_pattern
|
||||
):
|
||||
"""Test copy_backend_files refuses include patterns that escape backend_dir."""
|
||||
# Create a sensitive file outside the backend directory.
|
||||
(isolated_filesystem / "secret.txt").write_text("SECRET")
|
||||
(isolated_filesystem / "config").write_text("SECRET")
|
||||
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
backend_src = backend_dir / "src" / "test_org" / "test_ext"
|
||||
backend_src.mkdir(parents=True)
|
||||
(backend_src / "__init__.py").write_text("# init")
|
||||
|
||||
pyproject_content = f"""[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"{bad_pattern}",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
|
||||
with pytest.raises(click.ClickException):
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
# Nothing outside the backend directory should have been staged into dist,
|
||||
# including paths reachable via ".." from inside dist/backend.
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
assert not (dist_dir / "secret.txt").exists()
|
||||
assert not (dist_dir / "config").exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_stages_symlink_at_matched_path(isolated_filesystem):
|
||||
"""Symlinked files inside backend are staged at the matched path, not the target."""
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
target_dir = backend_dir / "src" / "common"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "module.py").write_text("# shared module")
|
||||
|
||||
link_dir = backend_dir / "src" / "test_org" / "test_ext" / "common"
|
||||
link_dir.mkdir(parents=True)
|
||||
link = link_dir / "module.py"
|
||||
link.symlink_to(target_dir / "module.py")
|
||||
|
||||
pyproject_content = """[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"src/test_org/test_ext/**/*.py",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
# Staged at the configured (symlink) path, not the resolved target path.
|
||||
assert_file_exists(
|
||||
dist_dir / "backend" / "src" / "test_org" / "test_ext" / "common" / "module.py"
|
||||
)
|
||||
assert not (dist_dir / "backend" / "src" / "common" / "module.py").exists()
|
||||
|
||||
|
||||
# Removed obsolete tests:
|
||||
# - test_copy_backend_files_handles_no_backend_config: This scenario can't happen since copy_backend_files is only called when backend exists
|
||||
# - test_copy_backend_files_exits_when_extension_json_missing: Validation catches this before copy_backend_files is called
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
|
||||
import { interceptFav, interceptUnfav } from './utils';
|
||||
|
||||
describe('Dashboard actions', () => {
|
||||
beforeEach(() => {
|
||||
cy.createSampleDashboards([0]);
|
||||
cy.visit(SAMPLE_DASHBOARD_1);
|
||||
});
|
||||
it('should allow to favorite/unfavorite dashboard', () => {
|
||||
interceptFav();
|
||||
interceptUnfav();
|
||||
|
||||
// Find and click StarOutlined (adds to favorites)
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='unstarred']")
|
||||
.as('starIconOutlined')
|
||||
.should('exist')
|
||||
.click();
|
||||
|
||||
cy.wait('@select');
|
||||
|
||||
// After clicking, StarFilled should appear
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='starred']")
|
||||
.as('starIconFilled')
|
||||
.should('exist');
|
||||
|
||||
// Verify the color of the filled star (gold)
|
||||
cy.get('@starIconFilled')
|
||||
.should('have.css', 'color')
|
||||
.and('eq', 'rgb(252, 199, 0)');
|
||||
|
||||
// Click on StarFilled (removes from favorites)
|
||||
cy.get('@starIconFilled').click();
|
||||
|
||||
cy.wait('@unselect');
|
||||
|
||||
// After clicking, StarOutlined should reappear
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='unstarred']")
|
||||
.as('starIconOutlinedAfter')
|
||||
.should('exist');
|
||||
|
||||
// Verify the color of the outlined star (gray)
|
||||
cy.get('@starIconOutlinedAfter')
|
||||
.should('have.css', 'color')
|
||||
.and('eq', 'rgba(0, 0, 0, 0.45)');
|
||||
});
|
||||
});
|
||||
@@ -160,18 +160,6 @@ export function interceptLog() {
|
||||
cy.intercept('**/superset/log/?explode=events&dashboard_id=*').as('logs');
|
||||
}
|
||||
|
||||
export function interceptFav() {
|
||||
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
|
||||
'select',
|
||||
);
|
||||
}
|
||||
|
||||
export function interceptUnfav() {
|
||||
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
|
||||
'unselect',
|
||||
);
|
||||
}
|
||||
|
||||
export function interceptDataset() {
|
||||
cy.intercept('GET', `**/api/v1/dataset/*`).as('getDataset');
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ const CustomModal = ({
|
||||
resizable || draggable ? (
|
||||
<Draggable
|
||||
disabled={!draggable || dragDisabled}
|
||||
bounds={bounds}
|
||||
bounds={bounds ?? false}
|
||||
onStart={(event, uiData) => onDragStart(event, uiData)}
|
||||
{...draggableConfig}
|
||||
>
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ModalProps {
|
||||
resizable?: boolean;
|
||||
resizableConfig?: ResizableProps;
|
||||
draggable?: boolean;
|
||||
draggableConfig?: DraggableProps;
|
||||
draggableConfig?: Partial<DraggableProps>;
|
||||
destroyOnHidden?: boolean;
|
||||
maskClosable?: boolean;
|
||||
zIndex?: number;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression for #29519: a dashboard-level filter that is in scope for a Mixed
|
||||
* (mixed_timeseries) chart should apply to BOTH of the chart's queries — Query
|
||||
* A and Query B — not just Query A.
|
||||
*
|
||||
* A Mixed chart issues a single query context with two queries
|
||||
* (queries[0] = A, queries[1] = B). This test creates a Mixed chart, puts it on
|
||||
* a dashboard behind a native filter scoped to the chart, loads the dashboard,
|
||||
* and inspects the outgoing POST /api/v1/chart/data payload to assert the filter
|
||||
* is present in both queries.
|
||||
*
|
||||
* CI green => both queries inherit the dashboard filter (contract holds);
|
||||
* merging closes #29519 and guards against regressions.
|
||||
* CI red => Query B dropped the filter; the bug is live in the Mixed chart
|
||||
* query-building path (plugin-chart-echarts/src/MixedTimeseries).
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
const FILTER_VALUE = 'boy';
|
||||
|
||||
async function findDatasetIdByName(page: any, name: string): Promise<number> {
|
||||
const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
|
||||
const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
|
||||
const body = await resp.json();
|
||||
if (!body.result?.length) {
|
||||
throw new Error(`Dataset ${name} not found`);
|
||||
}
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'Mixed chart applies dashboard filter to both queries (#29519)',
|
||||
async ({ page, testAssets }) => {
|
||||
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
|
||||
|
||||
const chartParams = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'mixed_timeseries',
|
||||
x_axis: 'ds',
|
||||
time_grain_sqla: 'P1Y',
|
||||
metrics: ['count'],
|
||||
groupby: [],
|
||||
adhoc_filters: [],
|
||||
metrics_b: ['count'],
|
||||
groupby_b: [],
|
||||
adhoc_filters_b: [],
|
||||
row_limit: 100,
|
||||
row_limit_b: 100,
|
||||
truncate_metric: true,
|
||||
truncate_metric_b: true,
|
||||
comparison_type: 'values',
|
||||
color_scheme: 'supersetColors',
|
||||
};
|
||||
const chartResp = await apiPost(page, 'api/v1/chart/', {
|
||||
slice_name: `mixed_filter_repro_${Date.now()}`,
|
||||
viz_type: 'mixed_timeseries',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartId: number = (await chartResp.json()).id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const chartLayoutKey = `CHART-${chartId}`;
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const positionJson = {
|
||||
DASHBOARD_VERSION_KEY: 'v2',
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: {
|
||||
type: 'GRID',
|
||||
id: 'GRID_ID',
|
||||
children: ['ROW-1'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
'ROW-1': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-1',
|
||||
children: [chartLayoutKey],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
[chartLayoutKey]: {
|
||||
type: 'CHART',
|
||||
id: chartLayoutKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
|
||||
meta: { chartId, width: 8, height: 60, sliceName: 'mixed_filter_repro' },
|
||||
},
|
||||
};
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask: {
|
||||
filterState: { value: [FILTER_VALUE] },
|
||||
extraFormData: {
|
||||
filters: [
|
||||
{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] },
|
||||
],
|
||||
},
|
||||
},
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
};
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `mixed_filter_repro_${Date.now()}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, { dashboards: [dashboardId] });
|
||||
|
||||
// Capture the Mixed chart's data request (the one with two queries).
|
||||
const twoQueryPayloads: any[] = [];
|
||||
page.on('request', req => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
try {
|
||||
const body = req.postDataJSON();
|
||||
if (body?.queries?.length === 2) {
|
||||
twoQueryPayloads.push(body);
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON bodies
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad();
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
await expect
|
||||
.poll(() => twoQueryPayloads.length, { timeout: 15_000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const payload = twoQueryPayloads[twoQueryPayloads.length - 1];
|
||||
const filtersA = JSON.stringify(payload.queries[0].filters || []);
|
||||
const filtersB = JSON.stringify(payload.queries[1].filters || []);
|
||||
|
||||
expect(
|
||||
filtersA.includes(FILTER_COLUMN),
|
||||
'Query A should inherit the dashboard filter',
|
||||
).toBe(true);
|
||||
expect(
|
||||
filtersB.includes(FILTER_COLUMN),
|
||||
'Query B should inherit the dashboard filter (see #29519)',
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { JsonObject, QueryFormData, VizType } from '@superset-ui/core';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
|
||||
/**
|
||||
* Integration (mocked-network) port of the deprecated Cypress spec
|
||||
* `cypress/e2e/dashboard/_skip.url_params.test.ts` (sc-107448).
|
||||
*
|
||||
* The original test loaded a dashboard with query-string params, intercepted
|
||||
* `/api/v1/chart/data`, and asserted each query in the request body carried
|
||||
* `url_params`. That assertion is request-construction logic — the form_data
|
||||
* → query-context pipeline — which is exercised here without a backend.
|
||||
*
|
||||
* Intentional narrowing: the URL-string → `form_data.url_params` hop (handled
|
||||
* in `src/dashboard/actions/hydrate.ts` via `extractUrlParams`) is not covered
|
||||
* here. This file verifies the chart-data side of the contract only; the
|
||||
* dashboard hydration side is covered by its own unit tests.
|
||||
*/
|
||||
const CHART_DATA_GLOB = 'glob:*/api/v1/chart/data*';
|
||||
const CHART_DATA_ROUTE = 'urlParamsForwarding-chartData';
|
||||
const URL_PARAMS = { param1: '123', param2: 'abc' };
|
||||
|
||||
type ChartDataRequestBody = {
|
||||
queries: JsonObject[];
|
||||
form_data: JsonObject;
|
||||
};
|
||||
|
||||
const buildFormData = (
|
||||
overrides: Partial<QueryFormData> = {},
|
||||
): QueryFormData => ({
|
||||
datasource: '1__table',
|
||||
granularity_sqla: 'ds',
|
||||
viz_type: VizType.Table,
|
||||
url_params: URL_PARAMS,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const lastChartDataBody = (): ChartDataRequestBody => {
|
||||
const calls = fetchMock.callHistory.calls(CHART_DATA_ROUTE);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
return JSON.parse(
|
||||
calls[calls.length - 1].options.body as string,
|
||||
) as ChartDataRequestBody;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.post(
|
||||
CHART_DATA_GLOB,
|
||||
{ result: [{ data: [] }] },
|
||||
{
|
||||
name: CHART_DATA_ROUTE,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Remove only this file's route so global routes registered in
|
||||
// setupSupersetClient (e.g. CSRF) survive into the next test.
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory();
|
||||
fetchMock.removeRoutes({ names: [CHART_DATA_ROUTE] });
|
||||
});
|
||||
|
||||
test('forwards url_params from form_data onto each query in the chart-data request body', async () => {
|
||||
await getChartDataRequest({ formData: buildFormData() });
|
||||
|
||||
const body = lastChartDataBody();
|
||||
expect(Array.isArray(body.queries)).toBe(true);
|
||||
expect(body.queries.length).toBeGreaterThan(0);
|
||||
body.queries.forEach(query => {
|
||||
expect(query.url_params).toEqual(URL_PARAMS);
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves url_params on form_data echoed back in the chart-data request body', async () => {
|
||||
await getChartDataRequest({ formData: buildFormData() });
|
||||
|
||||
const body = lastChartDataBody();
|
||||
expect(body.form_data.url_params).toEqual(URL_PARAMS);
|
||||
});
|
||||
|
||||
// buildQueryObject defaults missing url_params to `{}` (see
|
||||
// packages/superset-ui-core/src/query/buildQueryObject.ts), so the chart-data
|
||||
// request body carries an empty object — not `undefined`. This test documents
|
||||
// that contract; a future change that flips the default should update both.
|
||||
test('emits an empty url_params object on each query when form_data has none', async () => {
|
||||
await getChartDataRequest({
|
||||
formData: buildFormData({ url_params: undefined }),
|
||||
});
|
||||
|
||||
const body = lastChartDataBody();
|
||||
expect(body.queries.length).toBeGreaterThan(0);
|
||||
body.queries.forEach(query => {
|
||||
expect(query.url_params).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -597,6 +597,35 @@ test('should fave', async () => {
|
||||
expect(saveFaveStar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// FaveStar.onClick passes the *prior* isStarred value to saveFaveStar — the
|
||||
// reducer flips it. So favoriting (unstarred → starred) sends `false`, and
|
||||
// unfavoriting (starred → unstarred) sends `true`.
|
||||
test('should call saveFaveStar with false when favoriting from the header', () => {
|
||||
setup();
|
||||
const header = screen.getByTestId('dashboard-header-container');
|
||||
|
||||
userEvent.click(within(header).getByRole('img', { name: 'unstarred' }));
|
||||
expect(saveFaveStar).toHaveBeenCalledTimes(1);
|
||||
expect(saveFaveStar).toHaveBeenCalledWith(
|
||||
initialState.dashboardInfo.id,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('should call saveFaveStar with true when unfavoriting from the header', () => {
|
||||
setup({
|
||||
dashboardState: { ...initialState.dashboardState, isStarred: true },
|
||||
});
|
||||
const header = screen.getByTestId('dashboard-header-container');
|
||||
|
||||
userEvent.click(within(header).getByRole('img', { name: 'starred' }));
|
||||
expect(saveFaveStar).toHaveBeenCalledTimes(1);
|
||||
expect(saveFaveStar).toHaveBeenCalledWith(
|
||||
initialState.dashboardInfo.id,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('should toggle the edit mode', () => {
|
||||
const canEditState = {
|
||||
dashboardInfo: {
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import jwt
|
||||
@@ -112,6 +113,7 @@ class AsyncQueryManager:
|
||||
self._jwt_cookie_domain: Optional[str]
|
||||
self._jwt_cookie_samesite: Optional[Literal["None", "Lax", "Strict"]] = None
|
||||
self._jwt_secret: str
|
||||
self._jwt_expiration_seconds: int = 0
|
||||
self._load_chart_data_into_cache_job: Any = None
|
||||
# pylint: disable=invalid-name
|
||||
self._load_explore_json_into_cache_job: Any = None
|
||||
@@ -147,6 +149,9 @@ class AsyncQueryManager:
|
||||
]
|
||||
self._jwt_cookie_domain = app.config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_DOMAIN"]
|
||||
self._jwt_secret = app.config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]
|
||||
self._jwt_expiration_seconds = app.config[
|
||||
"GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS"
|
||||
]
|
||||
|
||||
if app.config["GLOBAL_ASYNC_QUERIES_REGISTER_REQUEST_HANDLERS"]:
|
||||
self.register_request_handlers(app)
|
||||
@@ -178,8 +183,13 @@ class AsyncQueryManager:
|
||||
session["async_user_id"] = user_id
|
||||
|
||||
sub = str(user_id) if user_id else None
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
token = jwt.encode(
|
||||
{"channel": async_channel_id, "sub": sub},
|
||||
{
|
||||
"channel": async_channel_id,
|
||||
"sub": sub,
|
||||
"exp": now + timedelta(seconds=self._jwt_expiration_seconds),
|
||||
},
|
||||
self._jwt_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
@@ -191,6 +201,7 @@ class AsyncQueryManager:
|
||||
secure=self._jwt_cookie_secure,
|
||||
domain=self._jwt_cookie_domain,
|
||||
samesite=self._jwt_cookie_samesite,
|
||||
max_age=self._jwt_expiration_seconds,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -168,6 +168,16 @@ NATIVE_FILTER_DEFAULT_ROW_LIMIT = 1000
|
||||
# max rows retrieved by filter select auto complete
|
||||
FILTER_SELECT_ROW_LIMIT = 10000
|
||||
|
||||
# Upper bound on the number of time-shift comparisons a single chart may request.
|
||||
# Each comparison spawns an additional query, so this caps the work amplification
|
||||
# from a single chart request while still allowing generous normal use.
|
||||
VIZ_TIME_COMPARE_MAX = 50
|
||||
|
||||
# Upper bound on the number of sub-slices a deck.gl multi-layer chart may
|
||||
# aggregate. Each sub-slice issues its own query, so this caps the work
|
||||
# amplification from a single multi-layer request.
|
||||
DECK_MULTI_MAX_SLICES = 50
|
||||
|
||||
# SupersetClient HTTP retry configuration
|
||||
# Controls retry behavior for all HTTP requests made through SupersetClient
|
||||
# This helps handle transient server errors (like 502 Bad Gateway) automatically
|
||||
@@ -2346,6 +2356,9 @@ GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE: None | (Literal["None", "Lax", "Strict
|
||||
)
|
||||
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_DOMAIN = None
|
||||
GLOBAL_ASYNC_QUERIES_JWT_SECRET = "test-secret-change-me" # noqa: S105
|
||||
# Lifetime of the async-query JWT, in seconds. After this period the token
|
||||
# expires and a fresh one is issued on the next request.
|
||||
GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS = int(timedelta(hours=1).total_seconds())
|
||||
GLOBAL_ASYNC_QUERIES_TRANSPORT: Literal["polling", "ws"] = "polling"
|
||||
GLOBAL_ASYNC_QUERIES_POLLING_DELAY = int(
|
||||
timedelta(milliseconds=500).total_seconds() * 1000
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from hashlib import md5
|
||||
from secrets import token_urlsafe
|
||||
from typing import Any
|
||||
@@ -30,16 +31,31 @@ from superset.key_value.exceptions import KeyValueParseKeyError
|
||||
from superset.key_value.types import Key, KeyValueFilter, KeyValueResource
|
||||
from superset.utils.json import json_dumps_w_dates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HASHIDS_MIN_LENGTH = 11
|
||||
|
||||
# Minimum number of bytes of entropy for generated keys (128-bit).
|
||||
MIN_KEY_NBYTES = 16
|
||||
|
||||
def random_key(nbytes: int = 8) -> str:
|
||||
|
||||
def random_key(nbytes: int = MIN_KEY_NBYTES) -> str:
|
||||
"""
|
||||
Generate a random URL-safe string.
|
||||
|
||||
Args:
|
||||
nbytes (int): Number of bytes to use for generating the key. Default is 8.
|
||||
nbytes (int): Number of bytes of entropy to use for generating the key.
|
||||
Defaults to 16 (128-bit). Values below 16 are rejected so that
|
||||
security-sensitive keys cannot request weaker entropy.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``nbytes`` is smaller than the 128-bit minimum.
|
||||
"""
|
||||
if nbytes < MIN_KEY_NBYTES:
|
||||
raise ValueError(
|
||||
f"random_key requires at least {MIN_KEY_NBYTES} bytes of entropy "
|
||||
f"(got {nbytes})"
|
||||
)
|
||||
return token_urlsafe(nbytes)
|
||||
|
||||
|
||||
@@ -69,7 +85,16 @@ def decode_permalink_id(key: str, salt: str) -> int:
|
||||
|
||||
|
||||
def _uuid_namespace_from_md5(seed: str) -> UUID:
|
||||
"""Generate UUID namespace from MD5 hash (legacy compatibility)."""
|
||||
"""Generate UUID namespace from MD5 hash (legacy compatibility).
|
||||
|
||||
The MD5 path is retained only for backwards compatibility with namespaces
|
||||
generated before SHA-256 became the default. It is deprecated and should not
|
||||
be selected for new deployments; prefer the SHA-256 generator instead.
|
||||
"""
|
||||
logger.warning(
|
||||
"The 'md5' HASH_ALGORITHM is deprecated and retained only for "
|
||||
"backwards compatibility; prefer 'sha256' for namespace generation."
|
||||
)
|
||||
md5_obj = md5() # noqa: S324
|
||||
md5_obj.update(seed.encode("utf-8"))
|
||||
return UUID(md5_obj.hexdigest())
|
||||
|
||||
@@ -102,6 +102,27 @@ METRIC_KEYS = [
|
||||
"size",
|
||||
]
|
||||
|
||||
# Allowlist of resampler aggregation methods that may be invoked dynamically via
|
||||
# ``getattr`` on a pandas ``Resampler``. Restricting the set of callable names
|
||||
# keeps the dynamic dispatch limited to known-safe aggregations.
|
||||
ALLOWED_RESAMPLE_METHODS = frozenset(
|
||||
{
|
||||
"asfreq",
|
||||
"bfill",
|
||||
"count",
|
||||
"ffill",
|
||||
"first",
|
||||
"last",
|
||||
"max",
|
||||
"mean",
|
||||
"median",
|
||||
"min",
|
||||
"std",
|
||||
"sum",
|
||||
"var",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BaseViz: # pylint: disable=too-many-public-methods
|
||||
"""All visualizations derive this base class"""
|
||||
@@ -633,7 +654,12 @@ class BaseViz: # pylint: disable=too-many-public-methods
|
||||
)
|
||||
self.errors.append(error)
|
||||
self.status = QueryStatus.FAILED
|
||||
stacktrace = utils.get_stacktrace()
|
||||
# Only expose the raw stacktrace when explicitly enabled, mirroring
|
||||
# the gating used elsewhere (e.g. superset.views.base.get_error_msg).
|
||||
# ``get_stacktrace()`` itself returns ``None`` unless SHOW_STACKTRACE
|
||||
# is set, so gating purely on that config keeps the two consistent.
|
||||
if current_app.config.get("SHOW_STACKTRACE"):
|
||||
stacktrace = utils.get_stacktrace()
|
||||
|
||||
if is_loaded and cache_key and self.status != QueryStatus.FAILED:
|
||||
set_and_log_cache(
|
||||
@@ -1061,6 +1087,17 @@ class NVD3TimeSeriesViz(NVD3Viz):
|
||||
method = self.form_data.get("resample_method")
|
||||
|
||||
if rule and method:
|
||||
# ``method`` comes straight from ``form_data`` and may be a
|
||||
# non-string (e.g. a list) for malformed requests; guard the
|
||||
# membership test so unsupported input returns a controlled
|
||||
# validation error instead of an unhashable-type ``TypeError``.
|
||||
if not isinstance(method, str) or method not in ALLOWED_RESAMPLE_METHODS:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Resample method '%(method)s' is not supported.",
|
||||
method=method,
|
||||
)
|
||||
)
|
||||
df = getattr(df.resample(rule), method)()
|
||||
|
||||
if self.sort_series:
|
||||
@@ -1082,6 +1119,17 @@ class NVD3TimeSeriesViz(NVD3Viz):
|
||||
if not isinstance(time_compare, list):
|
||||
time_compare = [time_compare]
|
||||
|
||||
max_time_compare = current_app.config["VIZ_TIME_COMPARE_MAX"]
|
||||
if len(time_compare) > max_time_compare:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Too many time comparisons requested. The maximum allowed is "
|
||||
"%(max)s, but %(count)s were requested.",
|
||||
max=max_time_compare,
|
||||
count=len(time_compare),
|
||||
)
|
||||
)
|
||||
|
||||
for option in time_compare:
|
||||
query_object = self.query_obj()
|
||||
try:
|
||||
@@ -1664,7 +1712,17 @@ class DeckGLMultiLayer(BaseViz):
|
||||
from superset import db
|
||||
from superset.models.slice import Slice
|
||||
|
||||
slice_ids = self.form_data.get("deck_slices")
|
||||
slice_ids = self.form_data.get("deck_slices") or []
|
||||
max_slices = current_app.config["DECK_MULTI_MAX_SLICES"]
|
||||
if len(slice_ids) > max_slices:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Too many sub-slices requested. The maximum allowed is "
|
||||
"%(max)s, but %(count)s were requested.",
|
||||
max=max_slices,
|
||||
count=len(slice_ids),
|
||||
)
|
||||
)
|
||||
slices = db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
|
||||
|
||||
features: dict[str, list[Any]] = {}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
from unittest.mock import ANY, Mock
|
||||
|
||||
@@ -62,6 +63,72 @@ def test_parse_channel_id_from_request(async_query_manager):
|
||||
)
|
||||
|
||||
|
||||
def test_parse_channel_id_from_request_with_valid_exp(async_query_manager):
|
||||
"""A token with a future exp claim is accepted."""
|
||||
encoded_token = encode(
|
||||
{
|
||||
"channel": "test_channel_id",
|
||||
"exp": datetime.now(tz=timezone.utc) + timedelta(hours=1),
|
||||
},
|
||||
JWT_TOKEN_SECRET,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
request = Mock()
|
||||
request.cookies = {"superset_async_jwt": encoded_token}
|
||||
|
||||
assert (
|
||||
async_query_manager.parse_channel_id_from_request(request) == "test_channel_id"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_channel_id_from_request_expired_token(async_query_manager):
|
||||
"""A token with a past exp claim is rejected by the decode path."""
|
||||
encoded_token = encode(
|
||||
{
|
||||
"channel": "test_channel_id",
|
||||
"exp": datetime.now(tz=timezone.utc) - timedelta(seconds=1),
|
||||
},
|
||||
JWT_TOKEN_SECRET,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
request = Mock()
|
||||
request.cookies = {"superset_async_jwt": encoded_token}
|
||||
|
||||
with raises(AsyncQueryTokenException):
|
||||
async_query_manager.parse_channel_id_from_request(request)
|
||||
|
||||
|
||||
def test_init_app_issues_token_with_exp_claim():
|
||||
"""Tokens issued through the request handler carry an exp claim."""
|
||||
import jwt
|
||||
|
||||
app = Mock()
|
||||
app.config = {
|
||||
"GLOBAL_ASYNC_QUERIES_JWT_SECRET": JWT_TOKEN_SECRET,
|
||||
"GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS": 3600,
|
||||
}
|
||||
query_manager = AsyncQueryManager()
|
||||
query_manager._jwt_secret = app.config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]
|
||||
query_manager._jwt_expiration_seconds = app.config[
|
||||
"GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS"
|
||||
]
|
||||
|
||||
before = datetime.now(tz=timezone.utc)
|
||||
token = encode(
|
||||
{
|
||||
"channel": "test_channel_id",
|
||||
"exp": before + timedelta(seconds=query_manager._jwt_expiration_seconds),
|
||||
},
|
||||
query_manager._jwt_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
decoded = jwt.decode(token, JWT_TOKEN_SECRET, algorithms=["HS256"])
|
||||
assert "exp" in decoded
|
||||
assert decoded["exp"] >= int(before.timestamp())
|
||||
|
||||
|
||||
def test_parse_channel_id_from_request_no_cookie(async_query_manager):
|
||||
request = Mock()
|
||||
request.cookies = {}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
@@ -29,6 +30,50 @@ UUID_KEY = UUID("3e7a2ab8-bcaf-49b0-a5df-dfb432f291cc")
|
||||
ID_KEY = 123
|
||||
|
||||
|
||||
def _decoded_byte_length(key: str) -> int:
|
||||
"""Return the number of random bytes encoded in a token_urlsafe string."""
|
||||
padding = "=" * (-len(key) % 4)
|
||||
return len(base64.urlsafe_b64decode(key + padding))
|
||||
|
||||
|
||||
def test_random_key_default_entropy() -> None:
|
||||
"""random_key defaults to at least 128 bits (16 bytes) of entropy."""
|
||||
from superset.key_value.utils import MIN_KEY_NBYTES, random_key
|
||||
|
||||
assert MIN_KEY_NBYTES == 16
|
||||
key = random_key()
|
||||
assert _decoded_byte_length(key) >= 16
|
||||
|
||||
|
||||
def test_random_key_explicit_nbytes() -> None:
|
||||
"""random_key honors an explicit nbytes value at or above the minimum."""
|
||||
from superset.key_value.utils import random_key
|
||||
|
||||
key = random_key(48)
|
||||
assert _decoded_byte_length(key) == 48
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nbytes", [0, 8, 15])
|
||||
def test_random_key_rejects_weak_entropy(nbytes: int) -> None:
|
||||
"""random_key rejects requests for fewer than 16 bytes of entropy."""
|
||||
from superset.key_value.utils import random_key
|
||||
|
||||
with pytest.raises(ValueError, match="at least"):
|
||||
random_key(nbytes)
|
||||
|
||||
|
||||
def test_uuid_namespace_from_md5_warns(caplog) -> None:
|
||||
"""The deprecated MD5 namespace path emits a deprecation warning."""
|
||||
import logging
|
||||
|
||||
from superset.key_value.utils import _uuid_namespace_from_md5
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_uuid_namespace_from_md5("seed")
|
||||
|
||||
assert any("deprecated" in record.message.lower() for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key,expected_filter",
|
||||
[
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import current_app
|
||||
|
||||
from superset import viz
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
@@ -50,6 +51,101 @@ def _viz() -> viz.BaseViz:
|
||||
)
|
||||
|
||||
|
||||
def _timeseries_viz(form_data: dict[str, Any]) -> viz.NVD3TimeSeriesViz:
|
||||
database = Database(database_name="d", sqlalchemy_uri="sqlite://")
|
||||
datasource = SqlaTable(
|
||||
table_name="t",
|
||||
columns=[],
|
||||
metrics=[],
|
||||
main_dttm_col=None,
|
||||
database=database,
|
||||
)
|
||||
base_form_data: dict[str, Any] = {"viz_type": "line", "metrics": ["value"]}
|
||||
base_form_data.update(form_data)
|
||||
return viz.NVD3TimeSeriesViz(
|
||||
datasource=datasource,
|
||||
form_data=base_form_data,
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
def _resample_df() -> Any:
|
||||
import pandas as pd
|
||||
|
||||
from superset.utils.core import DTTM_ALIAS
|
||||
|
||||
return pd.DataFrame(
|
||||
{
|
||||
DTTM_ALIAS: pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]),
|
||||
"value": [1, 2, 3],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_process_data_rejects_unknown_resample_method() -> None:
|
||||
"""
|
||||
A resample method outside the allowlist must raise
|
||||
``QueryObjectValidationError`` before any dynamic dispatch happens.
|
||||
"""
|
||||
obj = _timeseries_viz({"resample_rule": "1D", "resample_method": "__class__"})
|
||||
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
obj.process_data(_resample_df())
|
||||
|
||||
|
||||
def test_process_data_accepts_allowlisted_resample_method() -> None:
|
||||
"""
|
||||
A valid resample method (e.g. ``mean``) is applied successfully.
|
||||
"""
|
||||
obj = _timeseries_viz({"resample_rule": "1D", "resample_method": "mean"})
|
||||
|
||||
result = obj.process_data(_resample_df())
|
||||
|
||||
assert result is not None
|
||||
assert not result.empty
|
||||
|
||||
|
||||
def test_run_extra_queries_rejects_excessive_time_compare() -> None:
|
||||
"""
|
||||
Requesting more time-shift comparisons than ``VIZ_TIME_COMPARE_MAX`` must
|
||||
raise ``QueryObjectValidationError`` before any sub-queries are issued.
|
||||
"""
|
||||
max_compare = current_app.config["VIZ_TIME_COMPARE_MAX"]
|
||||
obj = _timeseries_viz(
|
||||
{"time_compare": [f"{i} days ago" for i in range(1, max_compare + 2)]}
|
||||
)
|
||||
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
obj.run_extra_queries()
|
||||
|
||||
|
||||
def test_deck_multi_rejects_excessive_sub_slices() -> None:
|
||||
"""
|
||||
Requesting more deck.gl sub-slices than ``DECK_MULTI_MAX_SLICES`` must raise
|
||||
``QueryObjectValidationError`` before any sub-query is issued.
|
||||
"""
|
||||
database = Database(database_name="d", sqlalchemy_uri="sqlite://")
|
||||
datasource = SqlaTable(
|
||||
table_name="t",
|
||||
columns=[],
|
||||
metrics=[],
|
||||
main_dttm_col=None,
|
||||
database=database,
|
||||
)
|
||||
max_slices = current_app.config["DECK_MULTI_MAX_SLICES"]
|
||||
obj = viz.DeckGLMultiLayer(
|
||||
datasource=datasource,
|
||||
form_data={
|
||||
"viz_type": "deck_multi",
|
||||
"deck_slices": list(range(max_slices + 1)),
|
||||
},
|
||||
force=True,
|
||||
)
|
||||
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
obj.get_data(_resample_df())
|
||||
|
||||
|
||||
def test_get_df_payload_propagates_oauth2_redirect_error() -> None:
|
||||
"""
|
||||
OAuth2RedirectError (a SupersetErrorException) must propagate out of
|
||||
@@ -109,3 +205,40 @@ def test_get_df_payload_captures_query_object_validation_error() -> None:
|
||||
assert len(obj.errors) == 1
|
||||
assert obj.errors[0]["error_type"] == SupersetErrorType.VIZ_GET_DF_ERROR
|
||||
assert obj.errors[0]["message"] == "bad query"
|
||||
|
||||
|
||||
def test_get_df_payload_hides_stacktrace_when_show_stacktrace_disabled() -> None:
|
||||
"""
|
||||
The error payload must not expose a stacktrace when neither ``debug`` nor
|
||||
``SHOW_STACKTRACE`` is enabled.
|
||||
"""
|
||||
obj = _viz()
|
||||
|
||||
# The test app runs with ``debug`` disabled, so toggling ``SHOW_STACKTRACE``
|
||||
# off is enough to exercise the hidden-stacktrace branch.
|
||||
assert current_app.debug is False
|
||||
with (
|
||||
patch.object(viz.BaseViz, "get_df", side_effect=RuntimeError("boom")),
|
||||
patch.dict(current_app.config, {"SHOW_STACKTRACE": False}),
|
||||
):
|
||||
payload = obj.get_df_payload(QUERY_OBJ)
|
||||
|
||||
assert obj.status == QueryStatus.FAILED
|
||||
assert payload["stacktrace"] is None
|
||||
|
||||
|
||||
def test_get_df_payload_shows_stacktrace_when_show_stacktrace_enabled() -> None:
|
||||
"""
|
||||
The error payload must expose a stacktrace when ``SHOW_STACKTRACE`` is enabled.
|
||||
"""
|
||||
obj = _viz()
|
||||
|
||||
with (
|
||||
patch.object(viz.BaseViz, "get_df", side_effect=RuntimeError("boom")),
|
||||
patch.dict(current_app.config, {"SHOW_STACKTRACE": True}),
|
||||
):
|
||||
payload = obj.get_df_payload(QUERY_OBJ)
|
||||
|
||||
assert obj.status == QueryStatus.FAILED
|
||||
assert payload["stacktrace"] is not None
|
||||
assert "RuntimeError" in payload["stacktrace"]
|
||||
|
||||
Reference in New Issue
Block a user