Compare commits

...

7 Commits

Author SHA1 Message Date
Joe Li
376b5c9491 test(chart): cover URL params forwarded to chart-data request body
Ports the deprecated Cypress dashboard spec _skip.url_params.test.ts
(removed in #40384) to a Jest integration test with a mocked network.

The request-construction contract — that form_data.url_params lands on
each query in the /api/v1/chart/data request body — is verifiable without
a backend. The new test drives getChartDataRequest through the real
buildV1ChartDataPayload → buildQueryContext → buildQueryObject path and
asserts the intercepted POST body's queries[].url_params and form_data.
A third case pins the existing default of {} when url_params is absent
so a future change to that default fails loudly.

Part of the Cypress→Jest/Playwright migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 09:29:27 -07:00
Evan Rusackas
8eda626466 fix: raise random_key entropy and add expiry to async query tokens (#40638)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 16:24:06 -07:00
Evan Rusackas
fe9818226d fix(viz): gate stacktrace behind SHOW_STACKTRACE and allowlist resample method (#40636)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 16:09:59 -07:00
Joe Li
1e8438a478 test(dashboard): migrate favorite toggle Cypress spec to RTL (#40872)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 16:03:59 -07:00
dependabot[bot]
8fdabc44f5 chore(deps): update react-draggable requirement from ^4.5.0 to ^4.6.0 in /superset-frontend/packages/superset-ui-core (#40841)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:56:20 -07:00
Evan Rusackas
e9e9245112 test(mixed-chart): dashboard filters should reach both Mixed chart queries (#29519) (#40818)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 15:55:41 -07:00
Evan Rusackas
580be2cf32 fix(extensions-cli): constrain backend include patterns to the backend directory (#40593)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 15:42:06 -07:00
16 changed files with 877 additions and 89 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)');
});
});

View File

@@ -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');
}

View File

@@ -369,7 +369,7 @@ const CustomModal = ({
resizable || draggable ? (
<Draggable
disabled={!draggable || dragDisabled}
bounds={bounds}
bounds={bounds ?? false}
onStart={(event, uiData) => onDragStart(event, uiData)}
{...draggableConfig}
>

View File

@@ -47,7 +47,7 @@ export interface ModalProps {
resizable?: boolean;
resizableConfig?: ResizableProps;
draggable?: boolean;
draggableConfig?: DraggableProps;
draggableConfig?: Partial<DraggableProps>;
destroyOnHidden?: boolean;
maskClosable?: boolean;
zIndex?: number;

View File

@@ -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);
},
);

View File

@@ -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({});
});
});

View File

@@ -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: {

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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]] = {}

View File

@@ -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 = {}

View File

@@ -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",
[

View File

@@ -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"]