Compare commits

..
Author SHA1 Message Date
Elizabeth Thompson b4fac82c39 fix(explore): catch TemplateError when validating access for query-backed form_data
check_query_access() calls raise_for_access(query=query), which Jinja-renders
the query's SQL to resolve table references. A malformed template surfaces
as a raw jinja2.exceptions.TemplateError instead of a Superset exception,
leaking as an opaque 500 from the explore form_data endpoints (used by the
chart Explore/Drill-by cache) whenever datasource_type=query.

Wrap the call and re-raise as the existing SupersetTemplateException (422),
matching the same conversion already used in datasets/api.py, and map it to
a proper response in ExploreFormDataRestApi's four handlers.
2026-08-24 16:41:44 +00:00
3f10d8b1cc fix(versioning): suppress automatic chart normalization changes (#43350)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 09:09:20 -07:00
Mehmet Salih Yavuz 15b98aa6da fix(plugin-chart-echarts): apply contribution before rename with time comparison (#43384) 2026-08-24 19:08:08 +03:00
7f1b41444c fix: add missing access checks to semantic-layer and theme endpoints (#43389)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:52:45 -07:00
8a32324184 fix: add missing ownership checks to tag, report-log, and dataset-schema endpoints (#43390)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:44:22 -07:00
Joe Li 4623d9219a fix: update in the wild link on community page (#43403) 2026-08-24 22:28:19 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dcac129b51 chore(deps): bump js-yaml from 5.2.3 to 5.3.0 in /docs (#43442)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:17:39 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7196ce8a0c chore(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#43445)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:16:57 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e7756aeef9 chore(deps): bump immer from 11.1.16 to 11.1.17 in /superset-frontend (#43448)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:16:27 +07:00
DanielSwift1992 af859717a7 chore: remove two stale codecov ignore paths (#43438) 2026-08-24 22:15:57 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8fa48d73f6 chore(deps-dev): bump concurrently from 10.0.4 to 10.0.5 in /superset-frontend (#43449)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:04:38 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 90a3d93002 chore(deps): bump dayjs from 1.11.21 to 1.11.22 in /superset-frontend (#43450)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:04:09 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5ffb72151e chore(deps): bump react-error-boundary from 6.1.2 to 6.1.3 in /superset-frontend (#43451)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:03:04 +07:00
Enzo Martellucci c3ed8b312d fix(security): surface extra_editors in dashboard/chart lists (#43392) 2026-08-24 11:49:29 +02:00
Elizabeth Thompson 9f505eb0cb fix(examples): replace deprecated timeseries_limit_metric with series_limit_metric (#43422) 2026-08-22 15:02:20 -07:00
Joe LiandClaude Sonnet 5 f2610e9dca fix(sqllab): default PostgreSQL port to 5432 in the dynamic connection form (#43351)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:59:33 -07:00
abdullah revehaandAbdullah Sahin 09bd8c98de feat(chart): enable cross-filter on temporal x-axis (bar/label click) (#42792)
Co-authored-by: Abdullah Sahin <you@example.comclear>
2026-08-21 15:45:06 -07:00
Aurimas Navardauskas 5812c0eab5 fix(mcp): fail closed when MCP_AUTH_FACTORY raises (#43329) 2026-08-21 15:43:51 -07:00
Mike BridgeandClaude Fable 5 5f3e34908c fix(charts): surface blocking alerts/reports when archiving a chart (#43402)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:33:32 -03:00
Evan RusackasandSuperset Dev 9781254363 fix(export): escape formula-triggering values consistently and bound post-processing inputs (#43396)
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-08-21 12:37:33 -07:00
65893113fe fix(import): tighten ownership and validation checks across asset importers (#43393)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:35:33 -07:00
04017f3956 fix(sqllab): re-validate access against rendered SQL and tighten cache/permalink scoping (#43394)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:35:06 -07:00
2b6044b624 fix: tighten SSRF validation, executor resolution, and cache scoping across reports/thumbnails (#43395)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:34:37 -07:00
Evan RusackasandSuperset Dev aee98b527d fix(charts): escape untrusted strings before rendering into chart tooltips and popups (#43397)
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-08-21 12:31:31 -07:00
edda9d1bbb fix(frontend): tighten SQL Lab autorun scoping and HTML-rendering defaults (#43398)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:31:08 -07:00
Joe LiandClaude Sonnet 5 03eac279e5 fix(explore): exclude permalink_key from chart URL params (#43354)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 11:43:18 -07:00
yousophandClaude Opus 4.8 fd7095df9a fix(explore): align viz type gallery thumbnails and Featured tag (#43373)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-21 10:40:37 -07:00
d422f5b4b6 fix: last date label hidden on time series x-axis (#39899) (#42299)
Co-authored-by: Prathamesh Hukkeri <prathamesh04@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-21 09:59:06 -07:00
213 changed files with 10853 additions and 1122 deletions
-2
View File
@@ -3,9 +3,7 @@ codecov:
after_n_builds: 4
ignore:
- "superset/migrations/versions/*.py"
- "superset-frontend/packages/superset-ui-demo/**/*"
- "**/*.stories.tsx"
- "**/*.stories.jsx"
coverage:
status:
project:
+1 -1
View File
@@ -93,7 +93,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Copy image to GHCR
env:
@@ -75,13 +75,18 @@ jobs:
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50 --junit-xml=test-results/junit-unit.xml
# COVERAGE_FILE keeps these scoped gates off the default .coverage that
# the step above wrote. pytest-cov starts a fresh data file per run, so
# without it the last gate replaces the full-suite data and the report
# uploaded below contains only that gate's subtree -- every other file
# then reads as uncovered, and patch coverage on an unrelated PR is 0%.
- name: Python 100% coverage unit tests
env:
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
COVERAGE_FILE="${RUNNER_TEMP}/.coverage.sql" pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
COVERAGE_FILE="${RUNNER_TEMP}/.coverage.semantic_layers" pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
- name: Upload code coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
@@ -11,6 +11,11 @@ When soft-delete is enabled, deleting a chart, dashboard, or dataset archives it
instead of removing it permanently. The **Recently Archived** view lets owners
and admins find archived objects and restore them.
A chart used by an alert or report cannot be archived while that dependency
exists. In the chart list view, the archive confirmation lists the alerts and
reports that use the chart; a blocked attempt names them and asks you to
detach or delete them first.
:::note
This view is gated by the `SOFT_DELETE` feature flag. When the flag is off the
+1 -1
View File
@@ -66,7 +66,7 @@
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
"js-yaml": "^5.2.3",
"js-yaml": "^5.3.0",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
+1 -1
View File
@@ -67,7 +67,7 @@ const communityLinks = [
'Join our monthly virtual meetups and register for any upcoming events on Meetup',
},
{
url: 'https://github.com/apache/superset/blob/master/RESOURCES/INTHEWILD.md',
url: 'https://superset.apache.org/inTheWild/',
title: 'Organizations',
description:
'A list of some of the organizations using Superset in production.',
+4 -4
View File
@@ -10291,10 +10291,10 @@ js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, j
dependencies:
argparse "^2.0.1"
js-yaml@^5.2.3:
version "5.2.3"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.3.tgz#0942ae8f507e22eb0e54624871789cd477106e54"
integrity sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==
js-yaml@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.3.0.tgz#526430a6da31065127528ae695ce168cfc5f91f0"
integrity sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==
dependencies:
argparse "^2.0.1"
+23 -26
View File
@@ -86,7 +86,7 @@
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.21",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -100,7 +100,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.2",
"immer": "^11.1.16",
"immer": "^11.1.17",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
"js-levenshtein": "^1.1.6",
@@ -220,7 +220,7 @@
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
@@ -17190,9 +17190,9 @@
"license": "MIT"
},
"node_modules/concurrently": {
"version": "10.0.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz",
"integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==",
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
"integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -18541,9 +18541,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"version": "1.11.22",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
"license": "MIT"
},
"node_modules/debounce": {
@@ -23907,9 +23907,9 @@
"license": "MIT"
},
"node_modules/immer": {
"version": "11.1.16",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz",
"integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==",
"version": "11.1.17",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.17.tgz",
"integrity": "sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -34873,12 +34873,18 @@
}
},
"node_modules/react-error-boundary": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.2.tgz",
"integrity": "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.3.tgz",
"integrity": "sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-google-recaptcha": {
@@ -42962,7 +42968,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -42974,7 +42980,7 @@
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.7.1",
"react-error-boundary": "^6.1.2",
"react-error-boundary": "^6.1.3",
"react-js-cron": "^6.0.2",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
@@ -43073,15 +43079,6 @@
"node": ">=12"
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"packages/superset-ui-core/node_modules/react-ace": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
+3 -3
View File
@@ -163,7 +163,7 @@
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.21",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -177,7 +177,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.2",
"immer": "^11.1.16",
"immer": "^11.1.17",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
"js-levenshtein": "^1.1.6",
@@ -297,7 +297,7 @@
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
@@ -51,6 +51,20 @@ export interface MetricOptionProps {
shouldShowTooltip?: boolean;
}
/**
* `url` is an arbitrary caller-supplied string rendered as an href. Only
* http(s) and relative URLs become links; other schemes degrade to plain
* text.
*/
function isSafeHref(url: string): boolean {
try {
const { protocol } = new URL(url, window.location.origin);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}
export function MetricOption({
metric,
labelRef,
@@ -70,7 +84,7 @@ export function MetricOption({
`}
ref={labelRef}
>
{url ? (
{url && isSafeHref(url) ? (
<Typography.Link
href={url}
target={openInNewWindow ? '_blank' : ''}
@@ -112,3 +112,26 @@ test('shows a Tooltip for the verbose metric name', () => {
const { getByTestId } = setup();
expect(getByTestId('mock-tooltip')).toBeInTheDocument();
});
test('does not render javascript: URLs as links', () => {
// Regression test: the url prop can be creator-authored and must
// never become a script-bearing href for other viewers.
const { queryByRole, getByText } = setup({
url: 'javascript:alert(document.domain)', // eslint-disable-line no-script-url
});
expect(queryByRole('link')).not.toBeInTheDocument();
expect(getByText(defaultProps.metric.verbose_name)).toBeInTheDocument();
});
test('does not render data: URLs as links', () => {
const { queryByRole } = setup({
url: 'data:text/html,<script>alert(1)</script>',
});
expect(queryByRole('link')).not.toBeInTheDocument();
});
test('renders relative URLs as links', () => {
const { getByRole } = setup({
url: '/superset/dashboard/1/',
});
expect(
getByRole('link', { name: defaultProps.metric.verbose_name }),
).toHaveAttribute('href', '/superset/dashboard/1/');
});
@@ -67,7 +67,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -79,7 +79,7 @@
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.7.1",
"react-error-boundary": "^6.1.2",
"react-error-boundary": "^6.1.3",
"react-js-cron": "^6.0.2",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
@@ -49,5 +49,6 @@ export { default as finestTemporalGrainFormatter } from './formatters/finestTemp
export { default as normalizeTimestamp } from './utils/normalizeTimestamp';
export { default as denormalizeTimestamp } from './utils/denormalizeTimestamp';
export { default as createTimeRangeFromGranularity } from './utils/createTimeRangeFromGranularity';
export * from './types';
@@ -123,6 +123,25 @@ describe('isProbablyHTML', () => {
expect(isProbablyHTML('<canvas></canvas>')).toBe(true);
expect(isProbablyHTML('<iframe src="page.html"></iframe>')).toBe(true);
});
test('should return true for script-capable and foreign-content tags', () => {
expect(isProbablyHTML('<svg onload="alert(1)"></svg>')).toBe(true);
expect(isProbablyHTML('<math><mi>x</mi></math>')).toBe(true);
expect(
isProbablyHTML('<details open ontoggle="alert(1)">x</details>'),
).toBe(true);
expect(isProbablyHTML('<summary>x</summary>')).toBe(true);
expect(isProbablyHTML('<object data="x"></object>')).toBe(true);
expect(isProbablyHTML('<embed src="x">')).toBe(true);
expect(isProbablyHTML('<marquee>x</marquee>')).toBe(true);
expect(isProbablyHTML('<template>x</template>')).toBe(true);
expect(isProbablyHTML('<dialog open>x</dialog>')).toBe(true);
});
test('should return true for elements that parse into document.head', () => {
expect(isProbablyHTML('<style>body { display: none; }</style>')).toBe(true);
expect(isProbablyHTML('<title>injected</title>')).toBe(true);
});
});
describe('sanitizeHtmlIfNeeded', () => {
@@ -137,6 +156,24 @@ describe('sanitizeHtmlIfNeeded', () => {
const sanitizedString = sanitizeHtmlIfNeeded(plainText);
expect(sanitizedString).toEqual(plainText);
});
test('should sanitize svg/details/style payloads instead of passing them through', () => {
const svgPayload = '<svg onload="alert(document.cookie)"></svg>';
const sanitizedSvg = sanitizeHtmlIfNeeded(svgPayload);
expect(sanitizedSvg).not.toContain('<svg');
expect(sanitizedSvg).not.toContain('onload');
// `details` (with its `open` attribute) is in js-xss's default
// whitelist, so the tag itself survives sanitization; the fix is that
// the payload is now routed through FilterXSS at all, which strips the
// non-whitelisted `ontoggle` handler instead of returning it verbatim.
const detailsPayload = '<details open ontoggle="alert(1)">x</details>';
const sanitizedDetails = sanitizeHtmlIfNeeded(detailsPayload);
expect(sanitizedDetails).toEqual('<details open>x</details>');
const stylePayload = '<style>body { display: none; }</style>';
expect(sanitizeHtmlIfNeeded(stylePayload)).not.toContain('<style');
});
});
describe('safeHtmlSpan', () => {
@@ -154,6 +154,20 @@ const KNOWN_HTML_TAGS = new Set([
'html',
'head',
'body',
// Script-capable elements and foreign-content roots (SVG/MathML). These
// must be classified as HTML so that downstream sanitization is applied;
// omitting them makes the heuristic fail open — payloads such as
// `<svg onload=...>` or `<details open ontoggle=...>` would be classified
// "not HTML" and returned verbatim by sanitizeHtmlIfNeeded.
'svg',
'math',
'details',
'summary',
'object',
'embed',
'marquee',
'template',
'dialog',
]);
const HTML_TAG_PATTERN = new RegExp(
@@ -183,10 +197,15 @@ export function isProbablyHTML(text: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(cleanedStr, 'text/html');
// Check if parsing created actual HTML elements (not just text nodes)
const elements = Array.from(doc.body.childNodes).filter(
node => node.nodeType === 1,
) as Element[];
// Check if parsing created actual HTML elements (not just text nodes).
// Some elements (e.g. <style>, <title>, <meta>, <link>) parse into
// document.head rather than document.body, so both must be inspected —
// otherwise a bare <style> payload is classified "not HTML" and skips
// sanitization.
const elements = [
...Array.from(doc.head.childNodes),
...Array.from(doc.body.childNodes),
].filter(node => node.nodeType === 1) as Element[];
// If no elements were created, it's not HTML
if (elements.length === 0) {
@@ -40,10 +40,18 @@ import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiGet } from '../../helpers/api/requests';
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
import { getAccessToken } from '../../helpers/api/embedded';
import { TIMEOUT } from '../../utils/constants';
const DATASET_NAME = 'birth_names';
async function authorizeApi(page: Page): Promise<void> {
const accessToken = await getAccessToken(page);
await page.context().setExtraHTTPHeaders({
Authorization: `Bearer ${accessToken}`,
});
}
// Visible row text must never expose synthetic identifiers (layout node
// ids like CHART-xyz / ROW-… or bare UUIDs) — the rendering layer maps
// these to human names or kind-only phrasing.
@@ -79,19 +87,35 @@ async function currentUserSubjectId(page: Page): Promise<number> {
* this reads like its sibling specs, but fall back to whatever the instance
* has rather than requiring a particular fixture to be loaded.
*/
async function anyDatasetId(page: Page): Promise<number> {
async function anyDataset(page: Page): Promise<{
id: number;
columnName: string;
}> {
const named = await getDatasetByName(page, DATASET_NAME);
if (named) {
return named.id;
let datasetId = named?.id;
if (!datasetId) {
const res = await apiGet(
page,
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
);
expect(res.ok(), 'dataset list request').toBeTruthy();
const [first] = (await res.json()).result;
expect(first, 'the instance has at least one dataset').toBeTruthy();
datasetId = first.id;
}
const res = await apiGet(
page,
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
);
expect(res.ok(), 'dataset list request').toBeTruthy();
const [first] = (await res.json()).result;
expect(first, 'the instance has at least one dataset').toBeTruthy();
return first.id;
if (datasetId === undefined) {
throw new Error('Unable to resolve a dataset id');
}
const detailRes = await apiGet(page, `api/v1/dataset/${datasetId}`);
expect(detailRes.ok(), 'dataset detail request').toBeTruthy();
const { columns } = (await detailRes.json()).result;
const [firstColumn] = columns;
expect(firstColumn, 'the dataset has at least one column').toBeTruthy();
return {
id: datasetId,
columnName: firstColumn.column_name,
};
}
/** Open the Explore "Additional actions → View version history" panel. */
@@ -109,7 +133,8 @@ testWithAssets(
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const datasetId = await anyDatasetId(page);
await authorizeApi(page);
const { id: datasetId, columnName } = await anyDataset(page);
const baseName = `version_history_${Date.now()}`;
const chartResp = await apiPostChart(page, {
@@ -123,7 +148,7 @@ testWithAssets(
datasource: `${datasetId}__table`,
viz_type: 'table',
query_mode: 'raw',
all_columns: [],
all_columns: [columnName],
adhoc_filters: [],
row_limit: 10,
}),
@@ -171,3 +196,79 @@ testWithAssets(
).toBeFalsy();
},
);
testWithAssets(
'minor edit of a non-canonical chart omits hydration noise',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await authorizeApi(page);
const { id: datasetId, columnName } = await anyDataset(page);
const baseName = `version_history_normalization_${Date.now()}`;
const chartResp = await apiPostChart(page, {
slice_name: baseName,
viz_type: 'table',
datasource_id: datasetId,
datasource_type: 'table',
// Deliberately omit visualization defaults. Explore hydration supplies
// them, reproducing params imported before they were canonical.
params: JSON.stringify({
datasource: `${datasetId}__table`,
viz_type: 'table',
query_mode: 'raw',
all_columns: [columnName],
adhoc_filters: [],
extra_form_data: {},
dashboards: [],
row_limit: 10,
}),
});
expect(chartResp.ok(), 'chart creation').toBeTruthy();
const chartBody = await chartResp.json();
const chartId: number = chartBody.result?.id ?? chartBody.id;
expect(chartId, 'chart creation should return an id').toBeTruthy();
testAssets.trackChart(chartId);
const adminSubjectId = await currentUserSubjectId(page);
const editorResp = await apiPutChart(page, chartId, {
editors: [adminSubjectId],
});
expect(editorResp.ok(), 'claim chart editorship').toBeTruthy();
await page.goto(`explore/?slice_id=${chartId}`);
await page.getByRole('combobox', { name: 'Row limit' }).click();
await page.getByRole('option', { name: '100', exact: true }).click();
await page.locator('[data-test="query-save-button"]').click();
await page.locator('[data-test="save-overwrite-radio"]').click();
const saveResponsePromise = page.waitForResponse(
response =>
response.request().method() === 'PUT' &&
response.url().includes(`/api/v1/chart/${chartId}`),
);
await page.locator('[data-test="btn-modal-save"]').click();
const saveResponse = await saveResponsePromise;
expect(saveResponse.ok(), 'chart overwrite').toBeTruthy();
const requestPayload = saveResponse.request().postDataJSON();
const savedParams = JSON.parse(requestPayload.params);
expect(
savedParams.matrixify_enable,
'overwrite contains a default absent from the stored params',
).toBe(false);
await openVersionHistory(page);
const panel = page.locator('[aria-label="Version history"]');
const newestGroup = panel
.locator('[data-test="version-history-save-group"]')
.first();
await expect(newestGroup, 'shows the overwrite save group').toBeVisible();
await newestGroup.getByRole('button').first().click();
const rows = newestGroup.locator(
'[data-test="version-history-action-row"]',
);
await expect(rows, 'shows only the intentional edit').toHaveCount(1);
await expect(rows.first()).toContainText(/row limit/i);
},
);
@@ -27,11 +27,21 @@ const StyledTotalCell = styled.div`
`}
`;
// `align` originates from the chart's stored column_config
// (col.config.horizontalAlign), which can be set to an arbitrary string via
// a direct chart-params API write. Emotion compiles interpolated strings as
// CSS source, so the value must be clamped to a closed set of keywords
// before it reaches the stylesheet — never interpolated raw.
const ALLOWED_ALIGN_VALUES = new Set(['left', 'right', 'center']);
const safeAlign = (align?: string) =>
align && ALLOWED_ALIGN_VALUES.has(align) ? align : 'left';
const CellContainer = styled.div<{ backgroundColor?: string; align?: string }>`
display: flex;
background-color: ${({ backgroundColor }) =>
backgroundColor || 'transparent'};
justify-content: ${({ align }) => align || 'left'};
justify-content: ${({ align }) => safeAlign(align)};
`;
const ArrowContainer = styled.div<{ arrowColor?: string }>`
@@ -0,0 +1,59 @@
/**
* 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 '@testing-library/jest-dom';
import { render } from '@superset-ui/core/spec';
import { NumericCellRenderer } from '../src/renderers/NumericCellRenderer';
const renderCell = (horizontalAlign?: string) => {
const params = {
value: 42,
valueFormatted: '42',
node: { rowPinned: undefined, rowIndex: 0 },
hasBasicColorFormatters: false,
basicColorFormatters: [],
col: {
isNumeric: true,
config: horizontalAlign ? { horizontalAlign } : {},
},
valueRange: undefined,
alignPositiveNegative: false,
colorPositiveNegative: false,
} as unknown as Parameters<typeof NumericCellRenderer>[0];
return render(<NumericCellRenderer {...params} />);
};
const collectInjectedCss = () =>
Array.from(document.querySelectorAll('style'))
.map(style => style.textContent ?? '')
.join('\n');
test('applies an allowed horizontalAlign value from column config', () => {
const { container } = renderCell('center');
expect(container.firstChild).toHaveStyle({ justifyContent: 'center' });
});
test('does not compile a malicious horizontalAlign into the stylesheet', () => {
const payload =
'right;} & { position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:99999; background:#fff url(https://attacker.example/beacon) }';
const { container } = renderCell(payload);
const css = collectInjectedCss();
expect(css).not.toContain('position:fixed');
expect(css).not.toContain('attacker.example');
expect(container.firstChild).toHaveStyle({ justifyContent: 'left' });
});
@@ -38,3 +38,11 @@ export const convertUTCTimestampToLocal = (utcTimestamp: number): number => {
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
return utcTimestamp + offsetMs;
};
// Escapes HTML special characters before formatter output reaches an
// innerHTML sink. Mirrors plugin-chart-country-map's escapeHtml.
export const escapeHtml = (text: unknown): string => {
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
};
@@ -13,6 +13,7 @@ import d3tip from 'd3-tip';
import { t } from '@apache-superset/core/translation';
import { getContrastingColor } from '@superset-ui/core';
import { CALENDAR_TOOLTIP_CLASS } from '../tooltip';
import { escapeHtml } from '../utils';
var d3 = typeof require === 'function' ? require('d3') : window.d3;
@@ -22,14 +23,16 @@ var CalHeatMap = function () {
'use strict';
var self = this;
// d3-tip assigns the .html() return value to the tip node via
// innerHTML, so formatter output is HTML-escaped first.
self.tip = d3tip()
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
.direction('n')
.offset([-5, 0])
.html(
d => `
${self.options.timeFormatter(d.t)}: <strong>${self.options.valueFormatter(
d.v,
${escapeHtml(self.options.timeFormatter(d.t))}: <strong>${escapeHtml(
self.options.valueFormatter(d.v),
)}</strong>
`,
);
@@ -37,7 +40,7 @@ var CalHeatMap = function () {
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
.direction('n')
.offset([-5, 0])
.html(d => self.options.valueFormatter(d));
.html(d => escapeHtml(self.options.valueFormatter(d)));
this.allowedDataType = ['json', 'csv', 'tsv', 'txt'];
@@ -25,8 +25,12 @@ type FunctionalDateFormat = (date: Date) => string;
interface CalHeatMapInstance {
options: {
dateFormatter: DateFormatter | null;
timeFormatter: (t: number) => string;
valueFormatter: (v: number) => string;
};
formatDate(date: Date, format: string | FunctionalDateFormat): string;
tip: { html(): (d: { t: number; v: number }) => string };
legendTip: { html(): (d: number) => string };
}
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
@@ -59,3 +63,29 @@ test('CalHeatMap keeps the D3 formatter fallback', () => {
expect(calendar.formatDate(date, '%B')).toBe('January');
});
test('cell tooltip HTML escapes creator-controlled formatter output', () => {
// Regression test: the tip's .html() callback is assigned to the
// tooltip node via innerHTML (d3-tip), so formatter output must be
// escaped before it's returned.
const calendar = new CalHeatMap();
calendar.options.timeFormatter = () => '<img src=x onerror=alert(1)>';
calendar.options.valueFormatter = () => '<svg onload=alert(2)>';
const html = calendar.tip.html()({ t: 0, v: 1 });
expect(html).not.toContain('<img');
expect(html).not.toContain('<svg');
expect(html).toContain('&lt;img');
expect(html).toContain('&lt;svg');
});
test('legend tooltip HTML escapes creator-controlled formatter output', () => {
const calendar = new CalHeatMap();
calendar.options.valueFormatter = () => '<img src=x onerror=alert(1)>';
const html = calendar.legendTip.html()(1);
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
@@ -17,7 +17,11 @@
* under the License.
*/
import { getFormattedUTCTime, convertUTCTimestampToLocal } from '../src/utils';
import {
getFormattedUTCTime,
convertUTCTimestampToLocal,
escapeHtml,
} from '../src/utils';
test('getFormattedUTCTime formats local timestamp for display as UTC date', () => {
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
@@ -87,3 +91,22 @@ test('convertUTCTimestampToLocal and getFormattedUTCTime work together to displa
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
expect(formattedTime).toContain('2024-01-01');
});
test('escapeHtml neutralizes markup smuggled through a time format string', () => {
// Regression test: d3-time-format passes non-% characters through
// verbatim, so escaping must happen before the innerHTML sink.
const formatted = getFormattedUTCTime(
1704067200000,
'%Y <img src=x onerror=alert(1)>',
);
const escaped = escapeHtml(formatted);
expect(formatted).toContain('<img');
expect(escaped).not.toContain('<img');
expect(escaped).toContain('&lt;img');
});
test('escapeHtml stringifies non-string formatter output safely', () => {
expect(escapeHtml(1234)).toEqual('1234');
expect(escapeHtml('a & b < c')).toEqual('a &amp; b &lt; c');
});
@@ -33,6 +33,27 @@ import { WmsLayerConf, WfsLayerConf, LayerConf, XyzLayerConf } from '../types';
import { isWfsLayerConf, isWmsLayerConf, isXyzLayerConf } from '../typeguards';
import { isVersionBelow } from './serviceUtil';
/**
* Escape HTML special characters in a layer attribution string.
*
* OpenLayers' Attribution control renders attribution strings via innerHTML,
* and the attribution here comes from creator-supplied chart form data, so it
* must be treated as untrusted text rather than markup to prevent stored XSS.
*
* @param attribution The attribution string from the layer configuration
*
* @returns The attribution with HTML special characters escaped
*/
export const escapeAttribution = (attribution?: string): string | undefined =>
attribution === undefined
? undefined
: attribution
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
/**
* Create a WMS layer.
*
@@ -49,7 +70,7 @@ export const createWmsLayer = (wmsLayerConf: WmsLayerConf) => {
LAYERS: layersParam,
VERSION: version,
},
attributions: attribution,
attributions: escapeAttribution(attribution),
}),
});
};
@@ -66,7 +87,7 @@ export const createXyzLayer = (xyzLayerConf: XyzLayerConf) => {
return new TileLayer({
source: new XyzSource({
url,
attributions: attribution,
attributions: escapeAttribution(attribution),
}),
});
};
@@ -90,7 +111,7 @@ export const createWfsLayer = async (wfsLayerConf: WfsLayerConf) => {
const wfsSource = new VectorSource({
format: new GeoJSON(),
attributions: attribution,
attributions: escapeAttribution(attribution),
url: extent => {
const requestUrl = new URL(url);
const params = requestUrl.searchParams;
@@ -17,20 +17,65 @@
* under the License.
*/
import { WfsLayerConf } from '../../src/types';
import { WfsLayerConf, WmsLayerConf, XyzLayerConf } from '../../src/types';
import {
createLayer,
createWfsLayer,
createWmsLayer,
createXyzLayer,
escapeAttribution,
} from '../../src/util/layerUtil';
describe('layerUtil', () => {
describe('escapeAttribution', () => {
test('escapes HTML markup in attribution strings', () => {
expect(escapeAttribution('(c) OSM <img src=x onerror=alert(1)>')).toBe(
'(c) OSM &lt;img src=x onerror=alert(1)&gt;',
);
expect(escapeAttribution('a & "b" \'c\'')).toBe(
'a &amp; &quot;b&quot; &#039;c&#039;',
);
expect(escapeAttribution(undefined)).toBeUndefined();
});
});
describe('createWmsLayer', () => {
test('exists', () => {
// function is trivial
expect(createWmsLayer).toBeDefined();
});
test('escapes HTML in the layer attribution', () => {
const wmsLayerConf: WmsLayerConf = {
title: 'wms',
type: 'WMS',
url: 'https://ows-demo.terrestris.de/geoserver/osm/wms',
version: '1.3.0',
layersParam: 'osm:osm-fuel',
attribution: '(c) OSM <img src=x onerror=alert(1)>',
};
const layer = createWmsLayer(wmsLayerConf);
const attributions = layer.getSource()?.getAttributions();
expect(attributions?.(undefined as never)).toEqual([
'(c) OSM &lt;img src=x onerror=alert(1)&gt;',
]);
});
});
describe('createXyzLayer', () => {
test('escapes HTML in the layer attribution', () => {
const xyzLayerConf: XyzLayerConf = {
title: 'osm',
type: 'XYZ',
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '(c) OSM <img src=x onerror=alert(1)>',
};
const layer = createXyzLayer(xyzLayerConf);
const attributions = layer.getSource()?.getAttributions();
expect(attributions?.(undefined as never)).toEqual([
'(c) OSM &lt;img src=x onerror=alert(1)&gt;',
]);
});
});
describe('createWfsLayer', () => {
@@ -770,7 +770,9 @@ export default function transformProps(
nameGap: xAxisTitleMarginPx,
nameLocation: 'middle',
axisLabel: {
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
@@ -18,6 +18,7 @@
*/
import { t } from '@apache-superset/core/translation';
import { NumberFormatter } from '@superset-ui/core';
import { sanitizeHtml } from '../utils/series';
/*
function for finding the max metric values among all series data for Radar Chart
@@ -63,7 +64,7 @@ export const renderNormalizedTooltip = (
const { color, name = '', value: values } = params;
const seriesName = name || 'series0';
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${color}"></span>`;
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${sanitizeHtml(color)}"></span>`;
// Get metric values with denormalization if needed
const metricValues: TooltipMetricValue[] = metrics.map((metric, index) => {
@@ -85,19 +86,26 @@ export const renderNormalizedTooltip = (
};
});
// Tooltip is rendered via innerHTML (ECharts default renderMode
// 'html'), so seriesName/metric/value/color are HTML-escaped, matching
// the treatment every other echarts tooltip path applies.
const tooltipRows = metricValues
.map(
({ metric, value }) => `
<div style="display:flex;">
<div>${colorDot}${metric}:</div>
<div style="font-weight:bold;margin-left:auto;">${value}</div>
<div>${colorDot}${sanitizeHtml(metric)}:</div>
<div style="font-weight:bold;margin-left:auto;">${sanitizeHtml(
String(value),
)}</div>
</div>
`,
)
.join('');
return `
<div style="font-weight:bold;margin-bottom:5px;">${seriesName}</div>
<div style="font-weight:bold;margin-bottom:5px;">${sanitizeHtml(
seriesName,
)}</div>
${tooltipRows}
`;
};
@@ -21,8 +21,14 @@ import {
waitFor,
cleanup,
} from '../../../../spec/helpers/testing-library';
import { AxisType, TimeGranularity } from '@superset-ui/core';
import {
AxisType,
createTimeRangeFromGranularity,
DTTM_ALIAS,
TimeGranularity,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { logging } from '@apache-superset/core/utils';
import type { EChartsCoreOption } from 'echarts/core';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import type { ReactNode } from 'react';
@@ -93,12 +99,17 @@ afterAll(() => {
});
afterEach(() => {
jest.useRealTimers();
cleanup();
mockEchart.mockReset();
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
originalResizeObserver;
});
const advanceClickTimer = () => {
jest.advanceTimersByTime(300);
};
const defaultFormData: EchartsTimeseriesFormData & {
vizType: string;
dateFormat: string;
@@ -314,7 +325,8 @@ test('falls back to window resize listener when ResizeObserver is unavailable',
});
// Test for issue #25334: Bar chart cross-filter without dimensions
test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', async () => {
test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const propsWithCategoricalXAxis: TimeseriesChartTransformedProps = {
@@ -348,13 +360,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
dataIndex: 0,
});
// Wait for the timer (TIMER_DURATION = 300ms)
await waitFor(
() => {
expect(setDataMaskMock).toHaveBeenCalled();
},
{ timeout: 500 },
);
advanceClickTimer();
// Verify the cross-filter uses the X-axis column and value, not the metric
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
@@ -368,7 +374,8 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
}
});
test('emits cross-filter on category value for horizontal bar clicks', async () => {
test('emits cross-filter on category value for horizontal bar clicks', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
@@ -397,12 +404,7 @@ test('emits cross-filter on category value for horizontal bar clicks', async ()
dataIndex: 0,
});
await waitFor(
() => {
expect(setDataMaskMock).toHaveBeenCalled();
},
{ timeout: 500 },
);
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
@@ -424,9 +426,7 @@ test('uses rendered categorical axis for query event handlers', () => {
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'xAxis.category',
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('xAxis');
cleanup();
mockEchart.mockReset();
@@ -445,9 +445,7 @@ test('uses rendered categorical axis for query event handlers', () => {
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'yAxis.category',
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('yAxis');
});
test('emits cross-filter from horizontal categorical axis label clicks', () => {
@@ -473,8 +471,9 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
getLatestEchartProps().queryEventHandlers?.[0].handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: 'Product A',
} as ECElementEvent);
} as unknown as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
@@ -485,7 +484,8 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
]);
});
test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
test('does not emit duplicate cross-filter for generic axis label clicks', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
@@ -507,11 +507,28 @@ test('does not emit duplicate cross-filter for generic axis label clicks', async
name: 'Product A',
});
await new Promise(resolve => setTimeout(resolve, 400));
jest.advanceTimersByTime(400);
expect(setDataMaskMock).not.toHaveBeenCalled();
});
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
test('keeps temporal range exclusive ends on whole-second boundaries', () => {
const clickedTimestamp = new Date(Date.UTC(2021, 0, 15, 12, 34, 56, 789));
[TimeGranularity.DAY, TimeGranularity.MONTH, TimeGranularity.YEAR].forEach(
grain => {
const [, inclusiveEnd] = createTimeRangeFromGranularity(
clickedTimestamp,
grain,
false,
);
const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
expect(exclusiveEnd.getUTCMilliseconds()).toBe(0);
},
);
});
test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
const setDataMaskMock = jest.fn();
const propsWithTimeXAxis: TimeseriesChartTransformedProps = {
@@ -519,39 +536,610 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
emitCrossFilters: true,
setDataMask: setDataMaskMock,
groupby: [], // No dimensions
resolvedTimeGrain: TimeGranularity.DAY,
formData: {
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
},
xAxis: {
label: '__timestamp',
type: AxisType.Time, // Time-based X-axis (not categorical)
label: DTTM_ALIAS,
type: AxisType.Time,
},
};
render(<EchartsTimeseries {...propsWithTimeXAxis} />);
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps];
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
// Simulate a click event
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales',
data: [1609459200000, 100], // Timestamp
name: '2021-01-01',
dataIndex: 0,
});
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
},
]);
});
// Wait a bit and verify setDataMask was NOT called
await new Promise(resolve => setTimeout(resolve, 400));
expect(setDataMaskMock).not.toHaveBeenCalled();
}
test('emits TEMPORAL_RANGE cross-filter from time axis label click on month bucket', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE cross-filter from time axis label click on year bucket', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.YEAR}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.YEAR,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2022-01-01T00:00:00',
},
]);
});
test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE from string-typed time point click value', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: ['2021-01-01T00:00:00Z', 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('uses resolved time grain for temporal point-click cross-filter', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
extraFormData: {
time_grain_sqla: TimeGranularity.MONTH,
},
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [129, Date.UTC(2021, 0, 1)],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE cross-filter from horizontal time axis label click', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'yAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('warns and skips temporal axis label cross-filter when label value cannot be parsed', () => {
const setDataMaskMock = jest.fn();
const warn = jest.spyOn(logging, 'warn').mockImplementation();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: 'not-a-date',
} as unknown as ECElementEvent);
expect(setDataMaskMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'Unable to parse time axis value for cross-filtering',
'not-a-date',
);
warn.mockRestore();
});
test('logs and skips temporal point-click cross-filter when string value cannot be parsed', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const warn = jest.spyOn(logging, 'warn').mockImplementation();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: ['not-a-date', 100],
name: 'not-a-date',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'Unable to parse time axis value for cross-filtering',
'not-a-date',
);
warn.mockRestore();
});
test('emits empty temporal X-axis data mask when filter grain is missing', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: undefined,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
});
});
test('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
selectedValues={{ 0: selectedRange }}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
});
});
test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={['country']}
formData={{
...defaultFormData,
groupby: ['country'],
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMaskMock).not.toHaveBeenCalled();
});
test('does not emit temporal X-axis cross-filter when dimensions are set', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={['country']}
labelMap={{
Sales: ['US'],
}}
formData={{
...defaultFormData,
groupby: ['country'],
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'country',
op: 'IN',
val: ['US'],
},
]);
expect(
setDataMaskMock.mock.calls[0][0].extraFormData.filters.some(
(filter: { op: string }) => filter.op === 'TEMPORAL_RANGE',
),
).toBe(false);
});
// Test for issue #41102: horizontal bar cross-filter must use the category
// value, not the metric. For horizontal bars the data tuple is value-first
// (e.g. [100, 'Product A']), so relying on data[0] emitted the metric value.
test('emits cross-filter on the category value for a horizontal categorical bar', async () => {
test('emits cross-filter on the category value for a horizontal categorical bar', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const propsWithHorizontalXAxis: TimeseriesChartTransformedProps = {
@@ -585,12 +1173,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
dataIndex: 0,
});
await waitFor(
() => {
expect(setDataMaskMock).toHaveBeenCalled();
},
{ timeout: 500 },
);
advanceClickTimer();
// Must filter on the category ('Product A'), not the metric value (100)
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
@@ -604,6 +1187,55 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
}
});
test('context menu cross-filter is available for a temporal bar point', async () => {
const onContextMenuMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
onContextMenu={onContextMenuMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
extraFormData: {
time_grain_sqla: TimeGranularity.MONTH,
},
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
expect(contextMenuHandler).toBeDefined();
await contextMenuHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
await waitFor(() => {
expect(onContextMenuMock).toHaveBeenCalled();
});
const { crossFilter } = onContextMenuMock.mock.calls[0][2];
expect(crossFilter.dataMask.extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
// Test for issue #41102: the context-menu ("Add cross-filter") path must also
// use the category value, not the metric, for a horizontal categorical bar.
test('context menu cross-filter uses the category value for a horizontal categorical bar', async () => {
@@ -21,14 +21,17 @@ import {
DTTM_ALIAS,
BinaryQueryObjectFilterClause,
AxisType,
type TimeGranularity,
getTimeFormatter,
getColumnLabel,
getNumberFormatter,
LegendState,
ensureIsArray,
createTimeRangeFromGranularity,
} from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
import { logging } from '@apache-superset/core/utils';
import type {
ECElementEvent,
ViewRootGroup,
@@ -48,6 +51,29 @@ import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
import { ExtraControls } from '../components/ExtraControls';
const TIMER_DURATION = 300;
const getTimestampFromTimeAxisValue = (value: string | number) => {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
}
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) {
logging.warn('Unable to parse time axis value for cross-filtering', value);
}
return Number.isNaN(timestamp) ? undefined : timestamp;
};
// Day, month, and year ranges end at 23:59:59.999, so adding 1ms lands on a
// whole-second next bucket boundary. The formatter intentionally emits seconds.
const formatDateTime = (date: Date) =>
`${[
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0'),
].join('-')}T${[
String(date.getUTCHours()).padStart(2, '0'),
String(date.getUTCMinutes()).padStart(2, '0'),
String(date.getUTCSeconds()).padStart(2, '0'),
].join(':')}`;
// Percent-change draggable baseline handle geometry, in pixels.
const BASELINE_HANDLE_WIDTH = 8;
@@ -71,6 +97,7 @@ export default function EchartsTimeseries({
onFocusedSeries,
xValueFormatter,
xAxis,
resolvedTimeGrain,
refs,
emitCrossFilters,
coltypeMapping,
@@ -363,6 +390,65 @@ export default function EchartsTimeseries({
[selectedValues, xAxis.label],
);
const getTimeAxisCrossFilterDataMask = useCallback(
(clickedTimestamp: number) => {
const filterColumn =
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
const grain = resolvedTimeGrain as TimeGranularity | undefined;
if (!filterColumn || !grain) {
return {
dataMask: {
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
},
isCurrentValueSelected: false,
};
}
const [start, inclusiveEnd] = createTimeRangeFromGranularity(
new Date(clickedTimestamp),
grain,
false,
);
const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
const timeRange = `${formatDateTime(start)} : ${formatDateTime(exclusiveEnd)}`;
const selected: string[] = Object.values(selectedValues);
const isCurrentValueSelected = selected.includes(timeRange);
const values = isCurrentValueSelected ? [] : [timeRange];
return {
dataMask: {
extraFormData: {
filters:
values.length === 0
? []
: [
{
col: filterColumn,
op: 'TEMPORAL_RANGE' as const,
val: timeRange,
},
],
},
filterState: {
label: values.length ? values : undefined,
value: values.length ? values : null,
selectedValues: values.length ? values : null,
},
},
isCurrentValueSelected,
};
},
[formData.granularitySqla, resolvedTimeGrain, selectedValues, xAxis.label],
);
const handleChange = useCallback(
(value: string) => {
if (!emitCrossFilters) {
@@ -384,15 +470,26 @@ export default function EchartsTimeseries({
[emitCrossFilters, setDataMask, getXAxisCrossFilterDataMask],
);
const handleTimeAxisChange = useCallback(
(clickedTimestamp: number) => {
if (!emitCrossFilters) {
return;
}
setDataMask(getTimeAxisCrossFilterDataMask(clickedTimestamp).dataMask);
},
[emitCrossFilters, setDataMask, getTimeAxisCrossFilterDataMask],
);
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
const canCrossFilterByXAxis =
!hasDimensions && xAxis.type === AxisType.Category;
const categoryAxisValueIndex =
!hasDimensions &&
(xAxis.type === AxisType.Category || xAxis.type === AxisType.Time);
const xAxisValueIndex =
formData.orientation === OrientationType.Horizontal ? 1 : 0;
const getCategoryAxisValue = useCallback(
const getXAxisValue = useCallback(
(data: unknown, name: unknown) => {
if (Array.isArray(data)) {
const categoryAxisValue = data[categoryAxisValueIndex];
const categoryAxisValue = data[xAxisValueIndex];
if (
typeof categoryAxisValue === 'string' ||
typeof categoryAxisValue === 'number'
@@ -405,7 +502,7 @@ export default function EchartsTimeseries({
}
return undefined;
},
[categoryAxisValueIndex],
[xAxisValueIndex],
);
const eventHandlers: EventHandlers = {
@@ -423,15 +520,28 @@ export default function EchartsTimeseries({
// Cross-filter by dimension (original behavior)
const { seriesName: name } = props;
handleChange(name);
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
} else if (
canCrossFilterByXAxis &&
xAxis.type === AxisType.Category &&
props.componentType === 'series'
) {
// Cross-filter by X-axis value when no dimensions (issue #25334)
const categoryAxisValue = getCategoryAxisValue(
props.data,
props.name,
);
const categoryAxisValue = getXAxisValue(props.data, props.name);
if (categoryAxisValue !== undefined) {
handleXAxisChange(categoryAxisValue);
}
} else if (
canCrossFilterByXAxis &&
xAxis.type === AxisType.Time &&
props.componentType === 'series'
) {
const timeAxisValue = getXAxisValue(props.data, props.name);
if (timeAxisValue !== undefined) {
const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
if (timestamp !== undefined) {
handleTimeAxisChange(timestamp);
}
}
}
}, TIMER_DURATION);
},
@@ -466,17 +576,20 @@ export default function EchartsTimeseries({
];
const groupBy = ensureIsArray(formData.groupby);
if (data && xAxis.type === AxisType.Time) {
drillToDetailFilters.push({
col:
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
xAxis.label === DTTM_ALIAS
? formData.granularitySqla
: xAxis.label,
grain: formData.timeGrainSqla,
op: '==',
val: data[0],
formattedVal: xValueFormatter(data[0]),
});
const timeAxisValue = getXAxisValue(data, eventParams.name);
if (timeAxisValue !== undefined) {
drillToDetailFilters.push({
col:
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
xAxis.label === DTTM_ALIAS
? formData.granularitySqla
: xAxis.label,
grain: resolvedTimeGrain,
op: '==',
val: timeAxisValue,
formattedVal: xValueFormatter(timeAxisValue),
});
}
}
[
...(xAxis.type === AxisType.Category && data ? [xAxis.label] : []),
@@ -517,9 +630,7 @@ export default function EchartsTimeseries({
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
if (data && xAxis.type === AxisType.Time && xAxisCol) {
// For horizontal orientation the [x, value] pair is swapped
const xValue = Array.isArray(data)
? data[categoryAxisValueIndex]
: data;
const xValue = Array.isArray(data) ? data[xAxisValueIndex] : data;
const xAxisFilter = getTemporalXAxisDrillByFilter(
xAxisCol,
xValue,
@@ -530,10 +641,7 @@ export default function EchartsTimeseries({
xAxisFilters.push(xAxisFilter);
}
} else if (xAxis.type === AxisType.Category && xAxisCol) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
const categoryAxisValue = getXAxisValue(data, eventParams.name);
if (categoryAxisValue !== undefined) {
// A category axis can still sit on a temporal column when the
// axis is forced categorical; filter by time bucket in that case
@@ -564,15 +672,25 @@ export default function EchartsTimeseries({
crossFilter = getCrossFilterDataMask(seriesName);
} else if (
canCrossFilterByXAxis &&
xAxis.type === AxisType.Category &&
eventParams.componentType === 'series'
) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
const categoryAxisValue = getXAxisValue(data, eventParams.name);
if (categoryAxisValue !== undefined) {
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
}
} else if (
canCrossFilterByXAxis &&
xAxis.type === AxisType.Time &&
eventParams.componentType === 'series'
) {
const timeAxisValue = getXAxisValue(data, eventParams.name);
if (timeAxisValue !== undefined) {
const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
if (timestamp !== undefined) {
crossFilter = getTimeAxisCrossFilterDataMask(timestamp);
}
}
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
@@ -593,26 +711,39 @@ export default function EchartsTimeseries({
const { value } = event;
if (
canCrossFilterByXAxis &&
event.targetType === 'axisLabel' &&
(typeof value === 'string' || typeof value === 'number')
) {
handleXAxisChange(value);
if (xAxis.type === AxisType.Time) {
const timestamp = getTimestampFromTimeAxisValue(value);
if (timestamp !== undefined) {
handleTimeAxisChange(timestamp);
}
} else {
handleXAxisChange(value);
}
}
},
[canCrossFilterByXAxis, handleXAxisChange],
[
canCrossFilterByXAxis,
handleTimeAxisChange,
handleXAxisChange,
xAxis.type,
],
);
const categoryAxis =
const renderedXAxis =
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
const queryEventHandlers = useMemo(
() => [
{
name: 'click',
query: `${categoryAxis}.category`,
query: renderedXAxis,
handler: handleXAxisLabelClick,
},
],
[categoryAxis, handleXAxisLabelClick],
[renderedXAxis, handleXAxisLabelClick],
);
const zrEventHandlers: EventHandlers = {
@@ -102,15 +102,18 @@ export default function buildQuery(formData: QueryFormData) {
1. The resample, rolling, cum, timeCompare operators should be after pivot.
2. Resample must come before rolling so that imputed values are
included in the rolling window calculation.
3. the flatOperator makes multiIndex Dataframe into flat Dataframe
3. Contribution must come before rename because it relies on the
`__<time offset>` suffix to compute each time shift separately,
and rename strips that suffix.
4. the flatOperator makes multiIndex Dataframe into flat Dataframe
*/
post_processing: [
pivotOperatorInRuntime,
resampleOperator(formData, baseQueryObject),
rollingWindowOperator(formData, baseQueryObject),
timeCompareOperator(formData, baseQueryObject),
renameOperator(formData, baseQueryObject),
contributionOperator(formData, baseQueryObject, time_offsets),
renameOperator(formData, baseQueryObject),
sortOperator(formData, baseQueryObject),
flattenOperator(formData, baseQueryObject),
// todo: move prophet before flatten
@@ -1248,7 +1248,7 @@ export default function transformProps(
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
...(xAxisType === AxisType.Category &&
...((xAxisType === AxisType.Category || xAxisType === AxisType.Time) &&
groupBy.length === 0 && {
triggerEvent: true,
}),
@@ -1256,10 +1256,12 @@ export default function transformProps(
// When rotation is applied on time axes, hideOverlap can
// aggressively hide the last label. Rotated labels already
// have less overlap, so disabling hideOverlap is safe.
// At 0° rotation, keep hideOverlap to prevent long labels
// from overlapping each other, with showMaxLabel to ensure
// the last data point label stays visible (#37181).
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
// At 0° rotation, also disable hideOverlap when showMaxLabel
// is active so the forced boundary label is never suppressed
// by ECharts' overlap detection (#39899).
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
@@ -1598,6 +1600,7 @@ export default function transformProps(
label: xAxisLabel,
type: xAxisType,
},
resolvedTimeGrain,
refs,
coltypeMapping: dataTypes,
onLegendScroll,
@@ -122,5 +122,6 @@ export type TimeseriesChartTransformedProps =
label: string;
type: AxisType;
};
resolvedTimeGrain?: TimeGranularity;
onFocusedSeries: (series: string | null) => void;
};
@@ -28,6 +28,7 @@
*/
import { z } from 'zod';
import { sanitizeHtml } from '@superset-ui/core';
// =============================================================================
// Common Schemas
@@ -57,6 +58,33 @@ const fontStyleSchema = z.enum(['normal', 'italic', 'oblique']);
/** Symbol type */
const symbolTypeSchema = z.string();
/**
* With the ECharts default renderMode 'html', a string tooltip formatter is
* assigned to the tooltip DOM element via innerHTML. ECharts formatter
* strings commonly rely on inline markup (e.g. '{b}<br/>{c}') for layout, so
* rejecting every '<' would break that supported usage; instead the value is
* run through the same allowlist sanitizer used for other tooltip HTML,
* which keeps presentational tags and strips anything else.
*/
const sanitizedFormatterSchema = z
.string()
.transform(value => sanitizeHtml(value));
/**
* ECharts navigates to title.link/sublink on click, so restrict them to
* http(s) and same-origin relative paths.
*/
const safeLinkSchema = z
.string()
.refine(
value =>
/^https?:\/\//i.test(value) ||
(value.startsWith('/') && !value.startsWith('//')),
{
message: 'Only http(s) or same-origin relative URLs are allowed',
},
);
// =============================================================================
// Text Style Schema
// =============================================================================
@@ -168,11 +196,11 @@ export const titleSchema = z.object({
id: z.string().optional(),
show: z.boolean().optional(),
text: z.string().optional(),
link: z.string().optional(),
link: safeLinkSchema.optional(),
target: z.enum(['self', 'blank']).optional(),
textStyle: textStyleSchema.optional(),
subtext: z.string().optional(),
sublink: z.string().optional(),
sublink: safeLinkSchema.optional(),
subtarget: z.enum(['self', 'blank']).optional(),
subtextStyle: textStyleSchema.optional(),
textAlign: z.enum(['left', 'center', 'right']).optional(),
@@ -386,7 +414,9 @@ export const tooltipSchema = z.object({
z.array(z.union([z.number(), z.string()])),
])
.optional(),
formatter: z.string().optional(), // Only string formatters
// Only string formatters: a string tooltip formatter is rendered via
// innerHTML (default renderMode 'html'), so it is sanitized above.
formatter: sanitizedFormatterSchema.optional(),
padding: z.union([z.number(), z.array(z.number())]).optional(),
backgroundColor: colorSchema.optional(),
borderColor: colorSchema.optional(),
@@ -397,7 +427,9 @@ export const tooltipSchema = z.object({
shadowOffsetX: z.number().optional(),
shadowOffsetY: z.number().optional(),
textStyle: textStyleSchema.optional(),
extraCssText: z.string().optional(),
// `extraCssText` is intentionally not accepted; unknown keys are
// stripped by the schema, so configs that still carry it keep working
// minus the raw CSS.
order: z
.enum(['seriesAsc', 'seriesDesc', 'valueAsc', 'valueDesc'])
.optional(),
@@ -575,6 +607,9 @@ export const seriesSchema = z.object({
polarIndex: z.number().optional(),
geoIndex: z.number().optional(),
calendarIndex: z.number().optional(),
// Per-series `tooltip` is intentionally not admitted; the schema
// strips unknown keys. If per-series tooltips are ever admitted, reuse
// tooltipSchema so the formatter sanitization applies.
label: labelSchema.optional(),
labelLine: z
.object({
@@ -523,3 +523,73 @@ test('EChartOptionsParseError contains validation error details', () => {
);
}
});
// =============================================================================
// Creator-authored options must not reach the tooltip's innerHTML/
// navigation sinks unsanitized.
// =============================================================================
test('sanitizes tooltip string formatters instead of rejecting all markup', () => {
const input = `{ tooltip: { formatter: '<img src=x onerror=alert(1)>' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.tooltip).toEqual({ formatter: '<img src>' });
});
test('keeps presentational tags in tooltip string formatters', () => {
const input = `{ tooltip: { formatter: '{b}<br/>{c}' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.tooltip).toEqual({ formatter: '{b}<br />{c}' });
});
test('strips per-series tooltip config so its formatter never reaches the merge', () => {
const result = parseEChartOptions(
`{ series: [{ type: 'line', tooltip: { formatter: '<b onpointerover=alert(1)>x</b>' } }] }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ series: [{ type: 'line' }] });
});
test('accepts markup-free tooltip placeholder formatters', () => {
const input = `{ tooltip: { formatter: '{b}: {c}' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({ tooltip: { formatter: '{b}: {c}' } });
});
test('rejects javascript: URLs in title link and sublink', () => {
expect(() =>
parseEChartOptions(`{ title: { link: 'javascript:alert(1)' } }`),
).toThrow(EChartOptionsParseError);
expect(() =>
parseEChartOptions(`{ title: { sublink: 'javascript:alert(1)' } }`),
).toThrow(EChartOptionsParseError);
expect(() =>
parseEChartOptions(`{ title: { link: '//evil.example/x' } }`),
).toThrow(EChartOptionsParseError);
});
test('accepts http(s) and same-origin relative title links', () => {
const result = parseEChartOptions(
`{ title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' },
});
});
test('strips tooltip extraCssText instead of passing raw CSS through', () => {
const result = parseEChartOptions(
`{ tooltip: { show: true, extraCssText: 'background:url(//evil.example/x)' } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ tooltip: { show: true } });
});
@@ -1165,6 +1165,110 @@ test('x-axis dedup keeps the forced min label when the endpoints format identica
expect(formatter(min)).toBe('May');
});
test('#39899 - x-axis dates do not overlap and last label stays visible at 0° rotation (mixed)', () => {
// When showMaxLabel is active on a time axis with 0° rotation,
// hideOverlap must be off so ECharts cannot suppress the forced
// max label (the end-of-axis date).
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [
createTestQueryData(
[
{
__timestamp: Date.UTC(2026, 0, 1),
sum__num: 100,
},
{
__timestamp: Date.UTC(2026, 6, 1),
sum__num: 200,
},
],
{
colnames: ['__timestamp', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
},
),
createTestQueryData(
[
{
__timestamp: Date.UTC(2026, 0, 1),
sum__num: 100,
},
{
__timestamp: Date.UTC(2026, 6, 1),
sum__num: 200,
},
],
{
colnames: ['__timestamp', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
},
),
],
formData: {
...formData,
x_axis: '__timestamp',
metrics: ['sum__num'],
metricsB: ['sum__num'],
groupby: [],
groupbyB: [],
xAxisLabelRotation: 0,
// showMaxLabel (and therefore hideOverlap: false) only activates when
// a time grain resolves, so this needs one set to actually exercise
// the #39899 fix rather than silently no-op.
timeGrainSqla: TimeGranularity.MONTH,
},
queriesData: [
createTestQueryData(
[
{
__timestamp: Date.UTC(2026, 0, 1),
sum__num: 100,
},
{
__timestamp: Date.UTC(2026, 6, 1),
sum__num: 200,
},
],
{
colnames: ['__timestamp', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
},
),
createTestQueryData(
[
{
__timestamp: Date.UTC(2026, 0, 1),
sum__num: 100,
},
{
__timestamp: Date.UTC(2026, 6, 1),
sum__num: 200,
},
],
{
colnames: ['__timestamp', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
},
),
],
});
const { echartOptions } = transformProps(chartProps);
const { axisLabel } = echartOptions.xAxis as Record<string, any>;
expect(axisLabel.showMaxLabel).toBe(true);
expect(axisLabel.alignMaxLabel).toBe('right');
expect(axisLabel.hideOverlap).toBe(false);
});
test('regression #37921: multi-metric Query A with groupby does not duplicate first metric in series names', () => {
// Regression test for https://github.com/apache/superset/issues/37921
// ("Residual" follow-up to #37055).
@@ -70,4 +70,42 @@ describe('renderNormalizedTooltip', () => {
expect(tooltip).toContain('N/A');
expect(tooltip).not.toContain('NaN');
});
test('should HTML-escape series names from query data', () => {
// Regression test: the tooltip is rendered via innerHTML, so markup
// in query-result values must not become live DOM.
const tooltip = renderNormalizedTooltip(
{ ...params, name: '<img src=x onerror=alert(1)>' },
metrics,
mockGetDenormalizedValue,
metricsWithCustomBounds,
);
expect(tooltip).not.toContain('<img');
expect(tooltip).toContain('&lt;img');
});
test('should HTML-escape metric labels', () => {
const tooltip = renderNormalizedTooltip(
params,
['<svg onload=alert(1)>', 'metric2'],
mockGetDenormalizedValue,
metricsWithCustomBounds,
);
expect(tooltip).not.toContain('<svg');
expect(tooltip).toContain('&lt;svg');
});
test('should HTML-escape the series color used for the tooltip color dot', () => {
// Regression test: `color` is interpolated into a style attribute
// unquoted, so an unescaped quote could break out of the attribute
// and inject markup.
const tooltip = renderNormalizedTooltip(
{ ...params, color: 'red" onmouseover="alert(1)' },
metrics,
mockGetDenormalizedValue,
metricsWithCustomBounds,
);
expect(tooltip).not.toContain('" onmouseover="alert(1)"');
expect(tooltip).toContain('&quot; onmouseover=&quot;alert(1)');
});
});
@@ -16,11 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render } from '@testing-library/react';
import { AxisType } from '@superset-ui/core';
import { render, waitFor } from '@testing-library/react';
import { AxisType, DTTM_ALIAS, TimeGranularity } from '@superset-ui/core';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import { logging } from '@apache-superset/core/utils';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import EchartsTimeseries from '../../src/Timeseries/EchartsTimeseries';
import { TimeseriesChartTransformedProps } from '../../src/Timeseries/types';
import {
OrientationType,
TimeseriesChartTransformedProps,
} from '../../src/Timeseries/types';
import type { EchartsProps } from '../../src/types';
// Percent-change draggable baseline: this is the one piece of the ECharts
// rebuilds with zero prior test coverage despite six separate production
@@ -40,12 +46,14 @@ let mockChart: {
convertFromPixel: jest.Mock;
getModel: jest.Mock;
};
const mockEchart = jest.fn();
jest.mock('../../src/components/Echart', () => {
const { forwardRef, useImperativeHandle } = jest.requireActual('react');
return {
__esModule: true,
default: forwardRef((_props: unknown, ref: unknown) => {
default: forwardRef((props: unknown, ref: unknown) => {
mockEchart(props);
useImperativeHandle(ref, () => ({
getEchartInstance: () => mockChart,
}));
@@ -115,6 +123,17 @@ function renderTimeseries(
);
}
function getLatestEchartProps() {
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps];
return props;
}
function advanceClickTimer() {
jest.advanceTimersByTime(300);
}
// Pulls the graphic descriptor for the draggable baseline handle out of the
// most recent setOption call, mirroring how ECharts itself would read it.
function getBaselineGraphic() {
@@ -126,6 +145,7 @@ function getBaselineGraphic() {
beforeEach(() => {
jest.clearAllMocks();
mockEchart.mockReset();
setupChartMock();
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
cb(0);
@@ -134,6 +154,7 @@ beforeEach(() => {
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
@@ -245,3 +266,400 @@ test('does not touch the chart instance when rebase is disabled', () => {
expect(mockChart.setOption).not.toHaveBeenCalled();
});
test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.DAY,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMask.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
},
],
},
filterState: {
label: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
value: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
selectedValues: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
},
});
});
test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('uses resolved time grain for temporal point-click cross-filter', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
extraFormData: {
time_grain_sqla: TimeGranularity.MONTH,
},
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE from string-typed time point click value', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: ['2021-01-01T00:00:00Z', 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
orientation: OrientationType.Horizontal,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [129, Date.UTC(2021, 0, 1)],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
selectedValues: { 0: selectedRange },
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
});
});
test('emits empty temporal X-axis data mask when filter grain is missing', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
formData: {
granularitySqla: 'ds',
timeGrainSqla: undefined,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
});
});
test('warns and skips temporal cross-filter when string value cannot be parsed', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
const warn = jest.spyOn(logging, 'warn').mockImplementation();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: ['not-a-date', 100],
name: 'not-a-date',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'Unable to parse time axis value for cross-filtering',
'not-a-date',
);
});
test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: ['country'],
formData: {
groupby: ['country'],
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMask).not.toHaveBeenCalled();
});
test('context menu cross-filter is available for a temporal bar point', async () => {
const onContextMenu = jest.fn();
renderTimeseries({
emitCrossFilters: true,
onContextMenu,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
extraFormData: {
time_grain_sqla: TimeGranularity.MONTH,
},
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
await getLatestEchartProps().eventHandlers?.contextmenu?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
await waitFor(() => {
expect(onContextMenu).toHaveBeenCalled();
});
const { crossFilter } = onContextMenu.mock.calls[0][2];
expect(crossFilter.dataMask.extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
@@ -64,6 +64,28 @@ describe('Timeseries buildQuery', () => {
expect(query.metrics).toEqual(['bar', 'baz']);
});
test('should apply contribution before rename with time comparison', () => {
// rename strips the `__<offset>` suffix that contribution relies on to
// compute each time shift separately
const queryContext = buildQuery({
...formData,
metrics: ['bar'],
x_axis: 'ds',
groupby: ['col1'],
contributionMode: 'row',
comparison_type: 'values',
time_compare: ['1 week ago'],
});
const [query] = queryContext.queries;
const operations = (query.post_processing || []).map(
operator => operator?.operation,
);
expect(operations).toContain('contribution');
expect(operations.indexOf('contribution')).toBeLessThan(
operations.indexOf('rename'),
);
});
test('should not order by timeseries limit if orderby provided', () => {
const queryContext = buildQuery({
...formData,
@@ -2083,6 +2083,39 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
expect(xAxis.triggerEvent).toBe(true);
});
test('temporal x-axis enables trigger events when no dimensions are set', () => {
const ts1 = 1745784000000;
const ts2 = 1745870400000;
const chartProps = createTestChartProps({
formData: {
metrics: ['metric'],
granularity_sqla: 'ds',
x_axis: '__timestamp',
},
queriesData: [
createTestQueryData(
[
{ __timestamp: ts1, metric: 10 },
{ __timestamp: ts2, metric: 20 },
],
{
colnames: ['__timestamp', 'metric'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
),
],
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as {
triggerEvent?: boolean;
type: string;
};
expect(xAxis.type).toBe(AxisType.Time);
expect(xAxis.triggerEvent).toBe(true);
});
test('temporal x coltype forced categorical yields a Category axis with date labels', () => {
// Issue #28204: with a temporal x-axis (e.g. weekly grain) the default Time
// scale places ticks at "nice" intervals that don't line up with the buckets.
@@ -2330,6 +2363,7 @@ test('tooltip time grain wiring: dashboard-level extraFormData time grain overri
});
const transformedProps = transformProps(chartProps);
expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.MONTH);
const tooltipFormatter = (
transformedProps.echartOptions as unknown as TooltipFormatterOptions
).tooltip.formatter;
@@ -2363,6 +2397,7 @@ test('tooltip time grain wiring: chart-level time grain drives the tooltip when
});
const transformedProps = transformProps(chartProps);
expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.YEAR);
const tooltipFormatter = (
transformedProps.echartOptions as unknown as TooltipFormatterOptions
).tooltip.formatter;
@@ -341,15 +341,15 @@ test('should configure time axis labels to show max label for last month visibil
);
});
test('x-axis dates do not overlap and last label stays visible at 0° rotation', () => {
test('#39899 - x-axis dates do not overlap and last label stays visible at 0° rotation', () => {
const result = transformProps(buildTimeseriesChartProps());
const { axisLabel } = result.echartOptions.xAxis as Record<string, any>;
expect(axisLabel.hideOverlap).toBe(true);
// showMaxLabel forces the last data point label to render even
// when hideOverlap is active, preventing the #37181 regression.
// showMaxLabel forces the last data point label to render
expect(axisLabel.showMaxLabel).toBe(true);
expect(axisLabel.alignMaxLabel).toBe('right');
// hideOverlap must be OFF so ECharts cannot suppress the forced max label
expect(axisLabel.hideOverlap).toBe(false);
});
test('last x-axis date is visible and not cut off when rotated -45°', () => {
@@ -80,6 +80,19 @@ interface DatamapSource {
country?: string;
}
/**
* Escape HTML special characters to prevent XSS attacks. Popup templates are
* assigned to the hover element via innerHTML by the datamaps library, and
* formatter output can echo a creator-controlled format string verbatim
* (see createD3NumberFormatter's invalid-format fallback), so both the name
* and the formatted value must be treated as untrusted text.
*/
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
const propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
@@ -279,9 +292,9 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
highlightBorderWidth: 1,
popupTemplate: (geo, d) =>
d &&
`<div class="hoverinfo"><strong>${d.name}</strong><br>${formatter(
d.m1,
)}</div>`,
`<div class="hoverinfo"><strong>${escapeHtml(
d.name,
)}</strong><br>${escapeHtml(String(formatter(d.m1)))}</div>`,
},
bubblesConfig: {
borderWidth: 1,
@@ -290,9 +303,9 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
popupOnHover: !inContextMenu,
radius: null,
popupTemplate: (geo, d) =>
`<div class="hoverinfo"><strong>${d.name}</strong><br>${formatter(
d.m2,
)}</div>`,
`<div class="hoverinfo"><strong>${escapeHtml(
d.name,
)}</strong><br>${escapeHtml(String(formatter(d.m2)))}</div>`,
fillOpacity: 0.5,
animate: true,
highlightOnHover: !inContextMenu,
@@ -180,6 +180,33 @@ test('disables Datamaps highlightOnHover while the context menu is open', () =>
expect(geographyConfig?.highlightOnHover).toBe(false);
});
test('escapes markup in hover popup templates', () => {
// Regression test for stored XSS via the number-formatter fallback: an
// invalid Y Axis Format string is echoed verbatim by the formatter
// (createD3NumberFormatter's catch branch), so the popup templates must
// HTML-escape formatter output before datamaps assigns it via innerHTML.
const maliciousFormatter = getNumberFormatter('<img src=x onerror=alert(1)>');
WorldMap(container, { ...baseProps, formatter: maliciousFormatter });
const geographyConfig = lastDatamapConfig?.geographyConfig as {
popupTemplate: (geo: unknown, d: unknown) => string;
};
const bubblesConfig = lastDatamapConfig?.bubblesConfig as {
popupTemplate: (geo: unknown, d: unknown) => string;
};
const entry = { name: '<b>United States</b>', m1: 100, m2: 200 };
const geoPopup = geographyConfig.popupTemplate({}, entry);
const bubblePopup = bubblesConfig.popupTemplate({}, entry);
[geoPopup, bubblePopup].forEach(popup => {
expect(popup).not.toContain('<img');
expect(popup).not.toContain('<b>');
expect(popup).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(popup).toContain('&lt;b&gt;United States&lt;/b&gt;');
});
});
test('does not throw error when onContextMenu is undefined', () => {
const propsWithoutContextMenu = {
...baseProps,
@@ -1524,7 +1524,9 @@ export function popPermalink(key: string): SqlLabThunkAction<Promise<unknown>> {
dbId: json.dbId ? parseInt(json.dbId, 10) : undefined,
catalog: json.catalog ?? null,
schema: json.schema ?? undefined,
autorun: json.autorun ? json.autorun : false,
// The recipient must review the prefilled query and press
// Run; a permalink payload never auto-runs.
autorun: false,
sql: json.sql ? json.sql : 'SELECT ...',
templateParams: json.templateParams,
}),
@@ -1548,7 +1550,9 @@ export function popStoredQuery(
dbId: json.dbId ? parseInt(json.dbId, 10) : undefined,
catalog: json.catalog ?? null,
schema: json.schema ?? undefined,
autorun: json.autorun ? json.autorun : false,
// Same rule as popPermalink above — stored payloads never
// auto-run.
autorun: false,
sql: json.sql ? json.sql : 'SELECT ...',
templateParams: json.templateParams,
}),
@@ -1627,7 +1631,9 @@ export function popDatasourceQuery(
name: `${QUERY_TEXT} ${json.result.name}`,
dbId: json.result.database.id,
schema: json.result.schema,
autorun: sql !== undefined,
// `sql` here can come straight from the URL, so its mere
// presence must never imply auto-execution.
autorun: false,
sql: sql || json.result.select_star,
}),
),
@@ -719,7 +719,7 @@ const ResultSet = ({
if (data && data.length > 0) {
const allowHTML = getItem(
LocalStorageKeys.SqllabIsRenderHtmlEnabled,
true,
false,
);
const tableProps = {
@@ -284,7 +284,7 @@ const SqlEditor: FC<Props> = ({
getItem(LocalStorageKeys.SqllabIsAutocompleteEnabled, true),
);
const [renderHTMLEnabled, setRenderHTMLEnabled] = useState(
getItem(LocalStorageKeys.SqllabIsRenderHtmlEnabled, true),
getItem(LocalStorageKeys.SqllabIsRenderHtmlEnabled, false),
);
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
const [createAs, setCreateAs] = useState('');
@@ -1993,7 +1993,6 @@ function DatasourceEditor({
col => col.column_name,
)}
height={300}
allowHTML
/>
</>
)}
@@ -63,7 +63,7 @@ export const FilterableTable = ({
height,
filterText = '',
expandedColumns = [],
allowHTML = true,
allowHTML = false,
striped,
themeOverrides,
}: FilterableTableProps) => {
@@ -64,6 +64,35 @@ test('should render cellData value for default cell data', () => {
expect(container).toHaveTextContent('regular_text');
});
test('should render HTML cell data as inert text by default', () => {
const { container } = render(
<>
{renderResultCell({
cellData: '<img src="https://attacker.example/beacon.gif" />link',
columnKey: 'a',
})}
</>,
);
expect(container.querySelector('img')).not.toBeInTheDocument();
expect(container).toHaveTextContent(
'<img src="https://attacker.example/beacon.gif" />link',
);
});
test('should render sanitized HTML only when allowHTML is explicitly enabled', () => {
const { container } = render(
<>
{renderResultCell({
cellData: '<b>bold</b>',
columnKey: 'a',
allowHTML: true,
})}
</>,
);
expect(container.querySelector('b')).toBeInTheDocument();
expect(container).toHaveTextContent('bold');
});
test('should transform cell data by getCellContent for the regular text', () => {
const { container } = render(
<>
@@ -32,11 +32,15 @@ type Params = CellParams & {
getCellContent?: (args: CellParams) => string;
};
// Result cells carry untrusted warehouse data, so HTML rendering is opt-in:
// even sanitized markup keeps active capabilities (img/video fetch beacons,
// phishing anchors), which must not activate by default for data the viewer
// did not author.
export const renderResultCell = ({
cellData,
getCellContent,
columnKey,
allowHTML = true,
allowHTML = false,
}: Params) => {
const cellNode =
getCellContent?.({ cellData, columnKey }) ?? String(cellData);
+31
View File
@@ -0,0 +1,31 @@
/**
* 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 {
URL_PARAMS,
RESERVED_CHART_URL_PARAMS,
RESERVED_DASHBOARD_URL_PARAMS,
} from 'src/constants';
test('permalinkKey is reserved on both the chart and dashboard URL param lists', () => {
// Dashboard and explore permalinks resolve against different backend
// KV resources/salts, so a key from one must never leak into the other's
// URL via the reserved-params passthrough logic.
expect(RESERVED_DASHBOARD_URL_PARAMS).toContain(URL_PARAMS.permalinkKey.name);
expect(RESERVED_CHART_URL_PARAMS).toContain(URL_PARAMS.permalinkKey.name);
});
+1
View File
@@ -123,6 +123,7 @@ export const RESERVED_CHART_URL_PARAMS: string[] = [
URL_PARAMS.datasourceId.name,
URL_PARAMS.datasourceType.name,
URL_PARAMS.datasetId.name,
URL_PARAMS.permalinkKey.name,
URL_PARAMS.versionHistory.name,
];
export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
@@ -259,6 +259,21 @@ describe('isUserEditorOrAdmin', () => {
test('returns false when editors is omitted', () => {
expect(isUserEditorOrAdmin(outsiderUser)).toEqual(false);
});
test('returns true when the user is granted editorship only through extra_editors', () => {
expect(isUserEditorOrAdmin(editorUser, [], [10])).toEqual(true);
});
test('unions editors and extra_editors rather than preferring one', () => {
const nonMatchingSubject: Subject = { id: 999, label: 'Other', type: 1 };
expect(isUserEditorOrAdmin(editorUser, [nonMatchingSubject], [10])).toEqual(
true,
);
});
test('returns false when extra_editors names other subjects', () => {
expect(isUserEditorOrAdmin(editorUser, [], [999])).toEqual(false);
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -55,9 +55,6 @@ export const isUserInSubjects = (
);
};
const isUserInEditors = (editors: Subject[] = []): boolean =>
isUserInSubjects(editors);
export const isUserAdmin = (
user?: UserWithPermissionsAndRoles | UndefinedUser,
) =>
@@ -66,10 +63,12 @@ export const isUserAdmin = (
role => role.toLowerCase() === ADMIN_ROLE_NAME.toLowerCase(),
);
/** `extraEditors` is editorship granted via a deployment's EXTRA_EDITORS_RESOLVER. */
export const isUserEditorOrAdmin = (
user?: UserWithPermissionsAndRoles | UndefinedUser,
editors: Subject[] = [],
): boolean => isUserInEditors(editors) || isUserAdmin(user);
extraEditors?: SubjectRef[] | null,
): boolean => isUserInSubjects(editors, extraEditors) || isUserAdmin(user);
/**
* Editorship of *dashboard*, matching the server's `is_editor`: the explicit
@@ -17,10 +17,20 @@
* under the License.
*/
import { VizType } from '@superset-ui/core';
import { isFeatureEnabled, VizType } from '@superset-ui/core';
import { HYDRATE_CHART_NORMALIZATION } from 'src/features/versionHistory/reducer';
import { hydrateExplore, HYDRATE_EXPLORE } from './hydrateExplore';
import { exploreInitialData } from '../fixtures';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
beforeEach(() => mockedIsFeatureEnabled.mockReturnValue(false));
afterEach(() => {
window.history.pushState({}, '', '/');
});
@@ -343,3 +353,67 @@ test('extracts currency formats from metrics in dataset', () => {
}),
);
});
test('seeds only guarded matching-input hydration transitions', () => {
mockedIsFeatureEnabled.mockReturnValue(true);
const dispatch = jest.fn();
const getState = jest.fn(() => ({
user: {},
charts: {},
datasources: {},
common: { conf: { DEFAULT_TIME_FILTER: 'Last year' } },
explore: {},
}));
const persisted = {
...exploreInitialData.form_data,
};
delete persisted.time_range;
const initialData = {
...exploreInitialData,
form_data: { ...persisted },
slice: {
...exploreInitialData.slice!,
form_data: { ...persisted },
},
};
// @ts-expect-error focused hydration fixture
hydrateExplore(initialData)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: HYDRATE_CHART_NORMALIZATION,
tracking: expect.objectContaining({
chartId: 371,
transitions: expect.objectContaining({
time_range: {
control: 'time_range',
from_present: false,
to_present: true,
to_value: 'Last year',
},
}),
}),
}),
);
});
test('does not seed normalization metadata for dashboard overrides', () => {
mockedIsFeatureEnabled.mockReturnValue(true);
window.history.pushState({}, '', '/explore/?dashboard_id=12');
const dispatch = jest.fn();
const getState = jest.fn(() => ({
user: {},
charts: {},
datasources: {},
common: {},
explore: {},
}));
// @ts-expect-error focused hydration fixture
hydrateExplore(exploreInitialData)(dispatch, getState);
expect(dispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: HYDRATE_CHART_NORMALIZATION }),
);
});
@@ -49,6 +49,10 @@ import { getUrlParam } from 'src/utils/urlUtils';
import { URL_PARAMS } from 'src/constants';
import { findPermission } from 'src/utils/findPermission';
import getBootstrapData from 'src/utils/getBootstrapData';
import { nanoid } from 'nanoid';
import cloneDeep from 'lodash-es/cloneDeep';
import { hydrateChartNormalization } from 'src/features/versionHistory/reducer';
import { automaticNormalizationTransitions } from 'src/features/versionHistory/normalization';
enum ColorSchemeType {
CATEGORICAL = 'CATEGORICAL',
@@ -78,6 +82,8 @@ export const hydrateExplore =
const fallbackSlice = sliceId ? sliceEntities?.slices?.[sliceId] : null;
const initialSlice = slice ?? fallbackSlice;
const initialFormData = form_data ?? initialSlice?.form_data;
const persistedFormData = cloneDeep(initialSlice?.form_data ?? {});
const preHydrationFormData = cloneDeep(initialFormData ?? {});
const isCachedFormData = getUrlParam(URL_PARAMS.formDataKey) !== null;
const [primarySliceNameSource, fallbackSliceNameSource] = isCachedFormData
? [initialFormData, initialSlice]
@@ -213,6 +219,10 @@ export const hydrateExplore =
exploreState,
);
});
const hydratedFormData = {
...initialFormData,
...getFormDataFromControls(exploreState.controls),
};
const sliceFormData = initialSlice
? getFormDataFromControls(initialControls)
: null;
@@ -233,7 +243,7 @@ export const hydrateExplore =
lastRendered: 0,
};
return dispatch({
const result = dispatch({
type: HYDRATE_EXPLORE,
data: {
charts: {
@@ -253,6 +263,28 @@ export const hydrateExplore =
dataMask,
},
});
if (
isFeatureEnabled(FeatureFlag.VersionHistory) &&
initialSlice?.slice_id &&
!isCachedFormData &&
!dashboardId &&
getUrlParam(URL_PARAMS.vizType) === null
) {
dispatch(
hydrateChartNormalization({
chartId: initialSlice.slice_id,
hydrationSessionId: nanoid(),
transitions: automaticNormalizationTransitions(
persistedFormData,
preHydrationFormData,
hydratedFormData,
),
invalidatedControls: {},
saveAttemptId: null,
}),
);
}
return result;
};
export type HydrateExplore = {
@@ -21,6 +21,7 @@ import { Dispatch } from 'redux';
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
import {
DatasourceType,
isFeatureEnabled,
QueryFormData,
SimpleAdhocFilter,
VizType,
@@ -37,6 +38,13 @@ import {
} from './saveModalActions';
import { Operators } from '../constants';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
// Define test constants and mock data using imported types
const sliceId = 10;
const sliceName = 'New chart';
@@ -92,17 +100,159 @@ const sliceResponsePayload: Partial<PayloadSlice> = {
};
const sampleError = new Error('sampleError');
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
jest.mock('../exploreUtils', () => ({
buildV1ChartDataPayload: jest.fn(() => queryContext),
}));
beforeEach(() => fetchMock.clearHistory().removeRoutes());
beforeEach(() => {
fetchMock.clearHistory().removeRoutes();
mockedIsFeatureEnabled.mockReturnValue(false);
});
test('existing-chart overwrite sends only still-matching normalization metadata', async () => {
mockedIsFeatureEnabled.mockReturnValue(true);
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
name: updateSliceEndpoint,
});
const dispatch = jest.fn();
const getState = () => ({
explore: {
form_data: {
datasource: `${datasourceId}__${datasourceType}`,
viz_type: vizType,
row_limit: 10000,
show_legend: true,
object_control: { a: 1, b: 2 },
},
},
versionHistory: {
chartNormalization: {
chartId: sliceId,
hydrationSessionId: 'hydration-a',
saveAttemptId: null,
invalidatedControls: { show_legend: true as const },
transitions: {
row_limit: {
control: 'row_limit',
from_present: true as const,
from_value: null,
to_present: true as const,
to_value: 10000,
},
show_legend: {
control: 'show_legend',
from_present: false as const,
to_present: true as const,
to_value: true,
},
object_control: {
control: 'object_control',
from_present: false as const,
to_present: true as const,
to_value: { b: 2, a: 1 },
},
},
},
},
});
await updateSlice(
{ ...sliceResponsePayload, slice_id: sliceId } as never,
sliceName,
[],
)(dispatch, getState);
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
const body = JSON.parse(request?.options.body as string);
expect(body.normalization_changes).toEqual([
{
control: 'row_limit',
from_present: true,
from_value: null,
to_present: true,
to_value: 10000,
},
{
control: 'object_control',
from_present: false,
to_present: true,
to_value: { b: 2, a: 1 },
},
]);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'BEGIN_CHART_NORMALIZATION_SAVE' }),
);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'COMPLETE_CHART_NORMALIZATION_SAVE' }),
);
});
test('matches normalization metadata against finalized payload filters', async () => {
mockedIsFeatureEnabled.mockReturnValue(true);
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
name: updateSliceEndpoint,
});
const extraTemporalFilter = {
expressionType: 'SIMPLE',
clause: 'WHERE',
subject: 'ds',
operator: Operators.TemporalRange,
comparator: '',
isExtra: true,
} as SimpleAdhocFilter;
const savedTemporalFilter = {
...extraTemporalFilter,
comparator: 'No filter',
isExtra: false,
};
const dispatch = jest.fn();
const getState = () => ({
explore: {
form_data: {
datasource: `${datasourceId}__${datasourceType}`,
viz_type: vizType,
adhoc_filters: [extraTemporalFilter],
},
},
versionHistory: {
chartNormalization: {
chartId: sliceId,
hydrationSessionId: 'hydration-a',
saveAttemptId: null,
invalidatedControls: {},
transitions: {
adhoc_filters: {
control: 'adhoc_filters',
from_present: false as const,
to_present: true as const,
to_value: [savedTemporalFilter],
},
},
},
},
});
await updateSlice(
{ ...sliceResponsePayload, slice_id: sliceId } as never,
sliceName,
[],
)(dispatch, getState);
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
const body = JSON.parse(request?.options.body as string);
expect(body.normalization_changes).toEqual([
expect.objectContaining({
control: 'adhoc_filters',
to_value: [savedTemporalFilter],
}),
]);
});
/**
* Tests updateSlice action
*/
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
test('updateSlice handles success', async () => {
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
name: updateSliceEndpoint,
@@ -734,3 +884,97 @@ describe('getSlicePayload', () => {
});
});
});
test('existing-chart overwrite covers stash-removed keys as drop transitions', async () => {
mockedIsFeatureEnabled.mockReturnValue(true);
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
name: updateSliceEndpoint,
});
const dispatch = jest.fn();
const getState = () => ({
explore: {
// The stash removed order_desc from active form data...
form_data: {
datasource: `${datasourceId}__${datasourceType}`,
viz_type: vizType,
row_limit: 10000,
},
// ...and holds it with the value it had when hidden.
hiddenFormData: { order_desc: true },
},
versionHistory: {
chartNormalization: {
chartId: sliceId,
hydrationSessionId: 'hydration-drop',
saveAttemptId: null,
invalidatedControls: {},
transitions: {},
},
},
});
await updateSlice(
{
...sliceResponsePayload,
slice_id: sliceId,
// Persisted params carry the key the stash removed, same value.
form_data: { ...formData, order_desc: true },
} as never,
sliceName,
[],
)(dispatch, getState);
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
const body = JSON.parse(request?.options.body as string);
expect(body.normalization_changes).toEqual([
{
control: 'order_desc',
from_present: true,
from_value: true,
to_present: false,
},
]);
});
test('a stashed value the user changed before hiding is not covered', async () => {
mockedIsFeatureEnabled.mockReturnValue(true);
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
name: updateSliceEndpoint,
});
const dispatch = jest.fn();
const getState = () => ({
explore: {
form_data: {
datasource: `${datasourceId}__${datasourceType}`,
viz_type: vizType,
row_limit: 10000,
},
// Stash holds a USER-edited value; persisted differs, so the removal
// stays recorded.
hiddenFormData: { order_desc: false },
},
versionHistory: {
chartNormalization: {
chartId: sliceId,
hydrationSessionId: 'hydration-drop-2',
saveAttemptId: null,
invalidatedControls: {},
transitions: {},
},
},
});
await updateSlice(
{
...sliceResponsePayload,
slice_id: sliceId,
form_data: { ...formData, order_desc: true },
} as never,
sliceName,
[],
)(dispatch, getState);
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
const body = JSON.parse(request?.options.body as string);
expect(body.normalization_changes).toBeUndefined();
});
@@ -21,6 +21,8 @@ import { Dispatch } from 'redux';
import { t } from '@apache-superset/core/translation';
import {
DatasourceType,
FeatureFlag,
isFeatureEnabled,
type QueryFormData,
SimpleAdhocFilter,
SupersetClient,
@@ -30,11 +32,25 @@ import { isEmpty } from 'lodash-es';
import { Slice } from 'src/dashboard/types';
import { Operators } from '../constants';
import { buildV1ChartDataPayload } from '../exploreUtils';
import { nanoid } from 'nanoid';
import {
beginChartNormalizationSave,
completeChartNormalizationSave,
} from 'src/features/versionHistory/reducer';
import type {
AutomaticNormalizationTransitions,
ChartNormalizationTrackingState,
} from 'src/features/versionHistory/types';
import {
matchingAutomaticNormalizationTransitions,
stashDropNormalizationTransitions,
} from 'src/features/versionHistory/normalization';
export interface PayloadSlice extends Slice {
params: string;
dashboards: number[];
query_context: string;
normalization_changes?: AutomaticNormalizationTransitions[string][];
}
const ADHOC_FILTER_REGEX = /^adhoc_filters/;
@@ -233,21 +249,84 @@ export const updateSlice =
new?: boolean;
},
) =>
async (dispatch: Dispatch, getState: () => Partial<QueryFormData>) => {
async (
dispatch: Dispatch,
getState: () => Partial<QueryFormData> & {
versionHistory?: {
chartNormalization?: ChartNormalizationTrackingState | null;
};
explore?: {
form_data?: QueryFormData;
hiddenFormData?: Record<string, unknown>;
};
},
) => {
const { slice_id: sliceId, editors, form_data: formDataFromSlice } = slice;
const formData = getState().explore?.form_data;
const initialState = getState();
const formData = JSON.parse(
JSON.stringify(initialState.explore?.form_data ?? {}),
) as QueryFormData;
const tracking = initialState.versionHistory?.chartNormalization;
const saveAttemptId = nanoid();
const shouldAttachNormalization =
isFeatureEnabled(FeatureFlag.VersionHistory) &&
tracking?.chartId === sliceId;
if (shouldAttachNormalization) {
dispatch(
beginChartNormalizationSave(
sliceId,
tracking.hydrationSessionId,
saveAttemptId,
),
);
}
try {
const payload = await getSlicePayload(
sliceName,
formData,
dashboards,
editors as [],
formDataFromSlice,
);
const savedFormData = JSON.parse(payload.params ?? '{}') as QueryFormData;
// Hydration-time transitions that still hold, plus save-time drops of
// keys the stash removed (mutually exclusive per control: a surviving
// hydration transition implies the key is present in the payload, a
// stash drop implies it is absent).
const matchingTransitions = shouldAttachNormalization
? {
...matchingAutomaticNormalizationTransitions(
tracking,
savedFormData,
),
...stashDropNormalizationTransitions(
(formDataFromSlice ?? {}) as Record<string, unknown>,
initialState.explore?.hiddenFormData,
savedFormData,
),
}
: {};
if (
shouldAttachNormalization &&
Object.keys(matchingTransitions).length
) {
payload.normalization_changes = Object.values(matchingTransitions);
}
const response = await SupersetClient.put({
endpoint: `/api/v1/chart/${sliceId}`,
jsonPayload: await getSlicePayload(
sliceName,
formData,
dashboards,
editors as [],
formDataFromSlice,
),
jsonPayload: payload,
});
if (shouldAttachNormalization) {
dispatch(
completeChartNormalizationSave(
sliceId,
tracking.hydrationSessionId,
saveAttemptId,
{},
),
);
}
dispatch(saveSliceSuccess(response.json));
addToasts(false, sliceName, addedToDashboard).map(dispatch);
return response.json;
@@ -19,7 +19,6 @@
import { useMemo, useCallback, useRef, useState } from 'react';
import {
getTimeFormatter,
safeHtmlSpan,
TimeFormats,
getMetricLabel,
QueryFormMetric,
@@ -92,8 +91,11 @@ export function useGridColumns(
) {
return timeFormatter(value);
}
// Render string cells as plain text: this grid shows raw
// query results (untrusted warehouse data) to any viewer,
// so HTML must stay inert here even after sanitization.
if (typeof value === 'string') {
return safeHtmlSpan(value);
return value;
}
return String(value);
},
@@ -112,6 +112,31 @@ describe('ColorBreakpointOption', () => {
expect(colorPreview).toBeInTheDocument();
});
test('should coerce non-numeric color channels instead of injecting CSS', async () => {
// Stored form_data is opaque JSON, so a saved chart can carry a string
// channel crafted to break out of the styled-component declaration.
const maliciousBreakpoint = {
id: 3,
color: {
r: '0,0,0,1); } body { display: none } .x { background: rgba(0' as unknown as number,
g: 0,
b: 0,
a: 1,
},
minValue: 0,
maxValue: 100,
};
renderComponent({ breakpoint: maliciousBreakpoint });
const colorPreview = await screen.findByTestId('color-preview');
expect(colorPreview).toBeInTheDocument();
// The malicious string is coerced to a safe numeric channel (0), so the
// rendered rule is a plain rgba() value with no injected CSS.
expect(colorPreview).toHaveStyle({ background: 'rgba(0, 0, 0, 1)' });
expect(document.body).toBeVisible();
});
test('should handle decimal values', async () => {
const decimalBreakpoint: ColorBreakpointType = {
id: 2,
@@ -31,6 +31,19 @@ const BreakpointColorPreview = styled.div`
margin-right: ${({ theme }) => theme.sizeUnit}px;
`;
// Chart params are stored server-side as opaque JSON, so breakpoint color
// channels can arrive as arbitrary strings despite the compile-time numeric
// type. The formatted color is interpolated into a styled-component template
// (a stylesheet, not a per-property style assignment), so each channel must
// be coerced to a plain number to keep attacker-controlled strings from
// injecting CSS rules.
const toRgbChannel = (channel: unknown): number => {
const value = Number(channel);
return Number.isFinite(value)
? Math.min(255, Math.max(0, Math.round(value)))
: 0;
};
const ColorBreakpointOption = ({
breakpoint,
colorBreakpoints,
@@ -41,7 +54,9 @@ const ColorBreakpointOption = ({
const { color, minValue, maxValue } = breakpoint;
const formattedColor = color
? `rgba(${color.r}, ${color.g}, ${color.b}, 1)`
? `rgba(${toRgbChannel(color.r)}, ${toRgbChannel(color.g)}, ${toRgbChannel(
color.b,
)}, 1)`
: '';
return (
@@ -278,6 +278,24 @@ describe('VizTypeControl', () => {
).not.toBeInTheDocument();
});
test('Thumbnail labels expose the full chart name via a title tooltip', async () => {
// Labels are clamped to a fixed two-line block so every tile is the same
// height; the full (possibly truncated) name must stay discoverable through
// the title attribute.
await waitForRenderWrapper();
userEvent.click(screen.getByRole('tab', { name: 'All charts' }));
const visualizations = screen.getByTestId(getTestId('viz-row'));
const labels = await within(visualizations).findAllByTestId(
getTestId('viztype-label'),
);
expect(labels.length).toBeGreaterThan(0);
labels.forEach(label => {
expect(label).toHaveAttribute('title', label.textContent ?? '');
});
});
test('Submit on viz type double-click', async () => {
await waitForRenderWrapper();
userEvent.click(screen.getByRole('tab', { name: 'All charts' }));
@@ -214,6 +214,9 @@ const IconsPane = styled.div`
justify-content: space-evenly;
grid-gap: ${({ theme }) => theme.sizeUnit * 2}px;
justify-items: center;
/* top-align every tile so a longer chart name never pushes the thumbnails
of the other tiles in the same row upward */
align-items: start;
/* for some reason this padding doesn't seem to apply at the bottom of the container. Why is a mystery. */
padding: ${({ theme }) => theme.sizeUnit * 2}px;
`;
@@ -274,7 +277,6 @@ const thumbnailContainerCss = (theme: SupersetTheme) => css`
font: inherit;
cursor: pointer;
width: ${theme.sizeUnit * THUMBNAIL_GRID_UNITS}px;
position: relative;
outline: none; /* Remove focus outline to show only selected state */
img {
@@ -297,6 +299,16 @@ const thumbnailContainerCss = (theme: SupersetTheme) => css`
.viztype-label {
margin-top: ${theme.sizeUnit * 2}px;
text-align: center;
/* reserve a fixed two-line block so every tile is the same height,
regardless of how long the chart name is. Longer names are clamped
with an ellipsis; the full name stays available via the title tooltip. */
line-height: ${theme.sizeUnit * 4}px;
height: ${theme.sizeUnit * 8}px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
`;
@@ -320,10 +332,19 @@ const HighlightLabel = styled.div`
`}
`;
// Wraps the thumbnail image so the "Featured" badge can be anchored to the
// image itself rather than to the whole tile (whose height varies with the
// chart-name length). line-height: 0 removes the inline-image descender gap.
const ThumbnailImageWrapper = styled.div`
position: relative;
width: ${({ theme }) => theme.sizeUnit * THUMBNAIL_GRID_UNITS}px;
line-height: 0;
`;
const ThumbnailLabelWrapper = styled.div`
position: absolute;
right: ${({ theme }) => theme.sizeUnit}px;
top: ${({ theme }) => theme.sizeUnit * 19}px;
top: ${({ theme }) => theme.sizeUnit}px;
`;
const TitleLabelWrapper = styled.div`
@@ -367,27 +388,32 @@ const Thumbnail: FC<ThumbnailProps> = ({
onFocus={handleFocus}
data-test="viztype-selector-container"
>
<img
alt={type.name}
width="100%"
className={`viztype-selector ${isSelected ? 'selected' : ''}`}
src={
isDarkMode && type.thumbnailDark ? type.thumbnailDark : type.thumbnail
}
/>
<ThumbnailImageWrapper>
<img
alt={type.name}
width="100%"
className={`viztype-selector ${isSelected ? 'selected' : ''}`}
src={
isDarkMode && type.thumbnailDark
? type.thumbnailDark
: type.thumbnail
}
/>
{type.label && (
<ThumbnailLabelWrapper>
<HighlightLabel>
<div>{t(type.label)}</div>
</HighlightLabel>
</ThumbnailLabelWrapper>
)}
</ThumbnailImageWrapper>
<div
className="viztype-label"
data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__viztype-label`}
title={type.name}
>
{type.name}
</div>
{type.label && (
<ThumbnailLabelWrapper>
<HighlightLabel>
<div>{t(type.label)}</div>
</HighlightLabel>
</ThumbnailLabelWrapper>
)}
</button>
);
};
@@ -78,6 +78,37 @@ import { useExploreDataExport } from './useExploreDataExport';
export const SEARCH_THRESHOLD = 10;
/**
* Escape a single CSV cell value.
*
* Mirrors the server-side chokepoint (superset/utils/csv.py escape_value):
* values starting with a spreadsheet formula prefix (=, +, -, @, |, %, or a
* leading tab/carriage return, optionally behind leading whitespace) are
* neutralized with a leading single quote so exported cells cannot execute
* as formulas when opened in Excel/LibreOffice/Google Sheets. Plain negative
* numbers are left untouched. RFC-4180 quoting is applied afterwards.
*/
export const escapeCsvValue = (v: unknown): string => {
if (v === null || v === undefined) return '';
let s = String(v);
if (s.length > 0) {
const stripped = s.replace(/^\s+/, '');
const startsLikeFormula =
s[0] === '\t' ||
s[0] === '\r' ||
(stripped.length > 0 && '-@+|=%'.includes(stripped[0]));
const isNegativeNumber = s.length > 1 && /^-[0-9.]+$/.test(s);
if (startsLikeFormula && !isNegativeNumber) {
// Escape pipe to be extra safe (DDE payloads), then prefix with a
// single quote to prevent formula evaluation. Existing backslashes
// must be escaped first so the resulting `\|`/`\\` sequences are
// unambiguous to a downstream unescaper.
s = `'${s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|')}`;
}
}
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const MENU_KEYS = {
EDIT_PROPERTIES: 'edit_properties',
DASHBOARDS_ADDED_TO: 'dashboards_added_to',
@@ -488,15 +519,11 @@ export const useExploreAdditionalActionsMenu = (
filename: string,
) => {
if (!rows?.length || !columns?.length) return;
const esc = (v: unknown): string => {
if (v === null || v === undefined) return '';
const s = String(v);
const wrapped = /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
return wrapped;
};
const header = columns.map(c => esc(c.label ?? c.key ?? '')).join(',');
const header = columns
.map(c => escapeCsvValue(c.label ?? c.key ?? ''))
.join(',');
const body = rows
.map(r => columns.map(c => esc(r[c.key])).join(','))
.map(r => columns.map(c => escapeCsvValue(r[c.key])).join(','))
.join('\n');
const csv = `${header}\n${body}`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
@@ -25,6 +25,7 @@ import downloadAsPdf from 'src/utils/downloadAsPdf';
import {
useExploreAdditionalActionsMenu,
getExportScreenshotMenuItems,
escapeCsvValue,
} from './index';
import * as exploreUtils from 'src/explore/exploreUtils';
import { Slice } from 'src/types/Chart';
@@ -180,6 +181,45 @@ test('hides Edit chart properties from a chart editor lacking chart write permis
expect(screen.queryByText('Edit chart properties')).not.toBeInTheDocument();
});
test('escapeCsvValue neutralizes spreadsheet formula prefixes', () => {
// Mirrors superset/utils/csv.py escape_value so the client-built
// "Current View" CSV cannot ship live formulas (CSV injection).
expect(escapeCsvValue('=HYPERLINK("https://attacker.example")')).toBe(
`"'=HYPERLINK(""https://attacker.example"")"`,
);
expect(escapeCsvValue('@SUM(1+1)')).toBe(`'@SUM(1+1)`);
expect(escapeCsvValue('+cmd')).toBe(`'+cmd`);
expect(escapeCsvValue('%x')).toBe(`'%x`);
expect(escapeCsvValue('\t=1+1')).toBe(`'\t=1+1`);
expect(escapeCsvValue(' =1+1')).toBe(`' =1+1`);
expect(escapeCsvValue('=cmd|calc')).toBe(`'=cmd\\|calc`);
});
test('escapeCsvValue escapes pre-existing backslashes before escaping pipes', () => {
// A literal backslash sitting next to a pipe must not be left as-is: if it
// were, the escaped output (`\|`) would be indistinguishable from an
// escaped pipe, so a downstream unescaper couldn't recover the original
// value. Escaping backslashes first keeps the two cases unambiguous.
expect(escapeCsvValue('=cmd\\|calc')).toBe(`'=cmd\\\\\\|calc`);
});
test('escapeCsvValue RFC-4180-quotes a value containing a bare carriage return', () => {
// A raw \r inside a cell can be read as a record separator by some CSV
// consumers, so it must trigger outer quoting the same way \n does, even
// when it also triggered the formula-prefix guard above.
expect(escapeCsvValue('\r=1+1')).toBe(`"'\r=1+1"`);
});
test('escapeCsvValue keeps ordinary values intact', () => {
expect(escapeCsvValue('regular text')).toBe('regular text');
expect(escapeCsvValue('-12.5')).toBe('-12.5');
expect(escapeCsvValue(42)).toBe('42');
expect(escapeCsvValue(null)).toBe('');
expect(escapeCsvValue(undefined)).toBe('');
expect(escapeCsvValue('a,b')).toBe(`"a,b"`);
expect(escapeCsvValue('say "hi"')).toBe(`"say ""hi"""`);
});
test('shows 413 error toast when exportCSV fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
@@ -96,7 +96,11 @@ export default function ChartCard({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
const allowEdit = isUserEditorOrAdmin(user, chart.editors);
const allowEdit = isUserEditorOrAdmin(
user,
chart.editors,
chart.extra_editors,
);
const menuItems: MenuItem[] = [];
if (canEdit) {
@@ -83,7 +83,11 @@ function DashboardCard({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
const allowEdit = isUserEditorOrAdmin(user, dashboard.editors);
const allowEdit = isUserEditorOrAdmin(
user,
dashboard.editors,
dashboard.extra_editors,
);
const digest = dashboard.changed_on_utc || dashboard.changed_on;
const thumbnailUrl =
isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
@@ -136,6 +136,7 @@ describe('DatabaseModal', () => {
format: 'int32',
maximum: 65536,
minimum: 0,
nullable: true,
type: 'integer',
},
query: {
@@ -153,7 +154,7 @@ describe('DatabaseModal', () => {
type: 'string',
},
},
required: ['database', 'host', 'port', 'username'],
required: ['database', 'host', 'username'],
type: 'object',
},
preferred: true,
@@ -0,0 +1,158 @@
/**
* 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 {
automaticNormalizationTransitions,
isJsonValue,
matchingAutomaticNormalizationTransitions,
stashDropNormalizationTransitions,
} from './normalization';
test('recognizes only values that JSON can represent faithfully', () => {
expect(isJsonValue({ nested: [null, true, 3, 'value'] })).toBe(true);
expect(isJsonValue(Number.NaN)).toBe(false);
expect(isJsonValue(Number.POSITIVE_INFINITY)).toBe(false);
expect(isJsonValue(new Date())).toBe(false);
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
expect(isJsonValue(cyclic)).toBe(false);
});
test('records hydration changes only when input matches persisted data', () => {
expect(
automaticNormalizationTransitions(
{ row_limit: null },
{ row_limit: null },
{ row_limit: 10000, show_legend: true },
),
).toEqual({
row_limit: {
control: 'row_limit',
from_present: true,
from_value: null,
to_present: true,
to_value: 10000,
},
show_legend: {
control: 'show_legend',
from_present: false,
to_present: true,
to_value: true,
},
});
expect(
automaticNormalizationTransitions(
{ row_limit: null },
{ row_limit: 500 },
{ row_limit: 10000 },
),
).toEqual({});
});
test('does not interpret a missing hydrated control as normalization', () => {
expect(
automaticNormalizationTransitions(
{ obsolete_control: true },
{ obsolete_control: true },
{},
),
).toEqual({});
});
test('keeps only valid, unchanged transitions for a save', () => {
const rowLimit = {
control: 'row_limit',
from_present: true as const,
from_value: null,
to_present: true as const,
to_value: 10000,
};
const tracking = {
chartId: 7,
hydrationSessionId: 'hydration-a',
saveAttemptId: null,
invalidatedControls: { show_legend: true as const },
transitions: {
row_limit: rowLimit,
show_legend: {
control: 'show_legend',
from_present: false as const,
to_present: true as const,
to_value: true,
},
},
};
expect(
matchingAutomaticNormalizationTransitions(tracking, {
row_limit: 10000,
show_legend: true,
}),
).toEqual({ row_limit: rowLimit });
});
test('covers a stash-removed key still equal to its persisted value', () => {
expect(
stashDropNormalizationTransitions(
{ order_desc: true, row_limit: 5000 },
{ order_desc: true },
{ row_limit: 5000 },
),
).toEqual({
order_desc: {
control: 'order_desc',
from_present: true,
from_value: true,
to_present: false,
},
});
});
test('does not cover a stashed value the user changed before it was hidden', () => {
expect(
stashDropNormalizationTransitions(
{ server_page_length: 10 },
{ server_page_length: 25 },
{},
),
).toEqual({});
});
test('does not cover stashed keys that were never persisted', () => {
expect(
stashDropNormalizationTransitions({}, { totals_aggregate: 'SUM' }, {}),
).toEqual({});
});
test('does not cover keys the outgoing payload still carries', () => {
expect(
stashDropNormalizationTransitions(
{ order_desc: true },
{ order_desc: true },
{ order_desc: true },
),
).toEqual({});
});
test('drop coverage requires a stash', () => {
expect(
stashDropNormalizationTransitions({ order_desc: true }, undefined, {}),
).toEqual({});
});
@@ -0,0 +1,197 @@
/**
* 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 isEqual from 'lodash-es/isEqual';
import type {
AutomaticNormalizationTransition,
AutomaticNormalizationTransitions,
ChartNormalizationTrackingState,
JsonValue,
} from './types';
const isJsonValueInternal = (
value: unknown,
ancestors: WeakSet<object>,
): value is JsonValue => {
if (
value === null ||
typeof value === 'string' ||
typeof value === 'boolean'
) {
return true;
}
if (typeof value === 'number') {
return Number.isFinite(value);
}
if (typeof value !== 'object' || ancestors.has(value)) {
return false;
}
ancestors.add(value);
let isJsonCompatible: boolean;
if (Array.isArray(value)) {
isJsonCompatible = value.every(item =>
isJsonValueInternal(item, ancestors),
);
} else {
const prototype = Object.getPrototypeOf(value);
isJsonCompatible =
(prototype === Object.prototype || prototype === null) &&
Object.values(value).every(item => isJsonValueInternal(item, ancestors));
}
ancestors.delete(value);
return isJsonCompatible;
};
export const isJsonValue = (value: unknown): value is JsonValue =>
isJsonValueInternal(value, new WeakSet());
/** Structural equality for JSON values, independent of object key order. */
export const jsonValuesEqual = (left: unknown, right: unknown) =>
isEqual(left, right);
interface NormalizationSnapshots {
control: string;
persisted: Record<string, unknown>;
input: Record<string, unknown>;
hydrated: Record<string, unknown>;
}
const automaticNormalizationTransition = ({
control,
persisted,
input,
hydrated,
}: NormalizationSnapshots): AutomaticNormalizationTransition | undefined => {
const fromPresent = Object.hasOwn(persisted, control);
const inputPresent = Object.hasOwn(input, control);
const toPresent = Object.hasOwn(hydrated, control);
const fromValue = persisted[control];
const inputValue = input[control];
const toValue = hydrated[control];
const inputMatchesPersisted =
fromPresent === inputPresent && jsonValuesEqual(fromValue, inputValue);
const hydrationChangedValue =
fromPresent !== toPresent || !jsonValuesEqual(fromValue, toValue);
// Disappearing keys (!toPresent) are deliberately not covered here:
// hydration itself never removes keys from the merged snapshot. Machine
// removals happen later, when StashFormDataContainer stashes invisible
// controls out of form_data — those are covered at save time by
// stashDropNormalizationTransitions, which uses the stash itself
// (explore.hiddenFormData) as the proof the removal was not user-made.
if (!inputMatchesPersisted || !toPresent || !hydrationChangedValue) {
return undefined;
}
if (!isJsonValue(toValue)) {
return undefined;
}
if (!fromPresent) {
return {
control,
from_present: false,
to_present: true,
to_value: toValue,
};
}
if (!isJsonValue(fromValue)) {
return undefined;
}
return {
control,
from_present: true,
from_value: fromValue,
to_present: true,
to_value: toValue,
};
};
export const automaticNormalizationTransitions = (
persisted: Record<string, unknown>,
input: Record<string, unknown>,
hydrated: Record<string, unknown>,
): AutomaticNormalizationTransitions => {
const transitions: AutomaticNormalizationTransitions = {};
const controls = new Set([...Object.keys(input), ...Object.keys(hydrated)]);
controls.forEach(control => {
const transition = automaticNormalizationTransition({
control,
persisted,
input,
hydrated,
});
if (transition) {
transitions[control] = transition;
}
});
return transitions;
};
/**
* Advisory transitions for keys the stash removed from form_data.
*
* StashFormDataContainer moves an invisible control's value out of
* ``form_data`` into ``explore.hiddenFormData``. That removal is
* machine-made by construction, but it happens in render effects after
* hydration, so hydration-time tracking cannot see it. This computes the
* matching drop transitions at save time: a key counts only when the stash
* holds it, the stashed value still equals the persisted value (a user edit
* before hiding breaks the equality and stays recorded), and the outgoing
* payload no longer carries the key. Keys absent from the stash e.g.
* removed by a viz-type switch are never covered.
*/
export const stashDropNormalizationTransitions = (
persisted: Record<string, unknown>,
hiddenFormData: Record<string, unknown> | undefined,
outgoingFormData: Record<string, unknown>,
): AutomaticNormalizationTransitions => {
const transitions: AutomaticNormalizationTransitions = {};
if (!hiddenFormData) {
return transitions;
}
Object.keys(hiddenFormData).forEach(control => {
if (!Object.hasOwn(persisted, control)) return;
if (Object.hasOwn(outgoingFormData, control)) return;
const fromValue = persisted[control];
if (!isJsonValue(fromValue)) return;
if (!jsonValuesEqual(hiddenFormData[control], fromValue)) return;
transitions[control] = {
control,
from_present: true,
from_value: fromValue,
to_present: false,
};
});
return transitions;
};
export const matchingAutomaticNormalizationTransitions = (
tracking: ChartNormalizationTrackingState | null | undefined,
formData: Record<string, unknown>,
): AutomaticNormalizationTransitions =>
Object.fromEntries(
Object.entries(tracking?.transitions ?? {}).filter(
([control, transition]) =>
!tracking?.invalidatedControls[control] &&
Object.hasOwn(formData, control) === transition.to_present &&
(!transition.to_present ||
jsonValuesEqual(formData[control], transition.to_value)),
),
);
@@ -18,10 +18,14 @@
*/
import versionHistoryReducer, {
appendVersionSessionLog,
beginChartNormalizationSave,
clearVersionPreview,
clearVersionSessionLog,
completeChartNormalizationSave,
closeVersionHistoryPanel,
openVersionHistoryPanel,
hydrateChartNormalization,
invalidateChartNormalizationControls,
selectIsChartVersionPreviewActive,
selectIsDashboardVersionPreviewActive,
selectVersionHistory,
@@ -145,3 +149,55 @@ test('per-entity preview selectors only match their own entity type', () => {
expect(selectIsChartVersionPreviewActive(state)).toBe(true);
expect(selectIsDashboardVersionPreviewActive(state)).toBe(false);
});
test('normalization tracking invalidates controls without re-adding transitions', () => {
let state = versionHistoryReducer(
initial,
hydrateChartNormalization({
chartId: 7,
hydrationSessionId: 'session-a',
transitions: {
row_limit: {
control: 'row_limit',
from_present: true,
from_value: null,
to_present: true,
to_value: 10000,
},
},
invalidatedControls: {},
saveAttemptId: null,
}),
);
state = versionHistoryReducer(
state,
invalidateChartNormalizationControls(['row_limit']),
);
expect(state.chartNormalization?.invalidatedControls).toEqual({
row_limit: true,
});
expect(state.chartNormalization?.transitions.row_limit).toBeDefined();
});
test('late save completion cannot rebase another hydration session', () => {
let state = versionHistoryReducer(
initial,
hydrateChartNormalization({
chartId: 7,
hydrationSessionId: 'session-b',
transitions: {},
invalidatedControls: {},
saveAttemptId: null,
}),
);
state = versionHistoryReducer(
state,
beginChartNormalizationSave(7, 'session-b', 'attempt-b'),
);
const unchanged = versionHistoryReducer(
state,
completeChartNormalizationSave(7, 'session-a', 'attempt-a', {}),
);
expect(unchanged).toBe(state);
expect(unchanged.chartNormalization?.saveAttemptId).toBe('attempt-b');
});
@@ -18,6 +18,8 @@
*/
import type {
ActivityInclude,
AutomaticNormalizationTransitions,
ChartNormalizationTrackingState,
SessionLogEntry,
VersionedEntityType,
VersionHistoryState,
@@ -33,6 +35,12 @@ export const VERSION_PREVIEW_APPLIED = 'VERSION_PREVIEW_APPLIED';
export const VERSION_RESTORED = 'VERSION_RESTORED';
export const APPEND_VERSION_SESSION_LOG = 'APPEND_VERSION_SESSION_LOG';
export const CLEAR_VERSION_SESSION_LOG = 'CLEAR_VERSION_SESSION_LOG';
export const HYDRATE_CHART_NORMALIZATION = 'HYDRATE_CHART_NORMALIZATION';
export const INVALIDATE_CHART_NORMALIZATION_CONTROLS =
'INVALIDATE_CHART_NORMALIZATION_CONTROLS';
export const BEGIN_CHART_NORMALIZATION_SAVE = 'BEGIN_CHART_NORMALIZATION_SAVE';
export const COMPLETE_CHART_NORMALIZATION_SAVE =
'COMPLETE_CHART_NORMALIZATION_SAVE';
/** Upper bound on retained unsaved-edit entries; older ones drop off. */
export const MAX_SESSION_LOG_ENTRIES = 50;
@@ -86,6 +94,31 @@ interface ClearSessionLogAction {
type: typeof CLEAR_VERSION_SESSION_LOG;
}
interface HydrateChartNormalizationAction {
type: typeof HYDRATE_CHART_NORMALIZATION;
tracking: ChartNormalizationTrackingState;
}
interface InvalidateChartNormalizationControlsAction {
type: typeof INVALIDATE_CHART_NORMALIZATION_CONTROLS;
controls: string[];
}
interface BeginChartNormalizationSaveAction {
type: typeof BEGIN_CHART_NORMALIZATION_SAVE;
chartId: number;
hydrationSessionId: string;
saveAttemptId: string;
}
interface CompleteChartNormalizationSaveAction {
type: typeof COMPLETE_CHART_NORMALIZATION_SAVE;
chartId: number;
hydrationSessionId: string;
saveAttemptId: string;
transitions: AutomaticNormalizationTransitions;
}
export type VersionHistoryAction =
| OpenPanelAction
| ClosePanelAction
@@ -95,7 +128,11 @@ export type VersionHistoryAction =
| PreviewAppliedAction
| VersionRestoredAction
| AppendSessionLogAction
| ClearSessionLogAction;
| ClearSessionLogAction
| HydrateChartNormalizationAction
| InvalidateChartNormalizationControlsAction
| BeginChartNormalizationSaveAction
| CompleteChartNormalizationSaveAction;
export const openVersionHistoryPanel = (
entityType: VersionedEntityType,
@@ -161,6 +198,44 @@ export const clearVersionSessionLog = (): ClearSessionLogAction => ({
type: CLEAR_VERSION_SESSION_LOG,
});
export const hydrateChartNormalization = (
tracking: ChartNormalizationTrackingState,
): HydrateChartNormalizationAction => ({
type: HYDRATE_CHART_NORMALIZATION,
tracking,
});
export const invalidateChartNormalizationControls = (
controls: string[],
): InvalidateChartNormalizationControlsAction => ({
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
controls,
});
export const beginChartNormalizationSave = (
chartId: number,
hydrationSessionId: string,
saveAttemptId: string,
): BeginChartNormalizationSaveAction => ({
type: BEGIN_CHART_NORMALIZATION_SAVE,
chartId,
hydrationSessionId,
saveAttemptId,
});
export const completeChartNormalizationSave = (
chartId: number,
hydrationSessionId: string,
saveAttemptId: string,
transitions: AutomaticNormalizationTransitions,
): CompleteChartNormalizationSaveAction => ({
type: COMPLETE_CHART_NORMALIZATION_SAVE,
chartId,
hydrationSessionId,
saveAttemptId,
transitions,
});
const initialState: VersionHistoryState = {
isPanelOpen: false,
entityType: null,
@@ -170,6 +245,7 @@ const initialState: VersionHistoryState = {
sessionLog: [],
restoreCount: 0,
lastRestoredEntityUuid: null,
chartNormalization: null,
};
export default function versionHistoryReducer(
@@ -240,6 +316,58 @@ export default function versionHistoryReducer(
}
case CLEAR_VERSION_SESSION_LOG:
return { ...state, sessionLog: [] };
case HYDRATE_CHART_NORMALIZATION:
return { ...state, chartNormalization: action.tracking };
case INVALIDATE_CHART_NORMALIZATION_CONTROLS: {
if (!state.chartNormalization || action.controls.length === 0) {
return state;
}
const invalidatedControls = {
...state.chartNormalization.invalidatedControls,
};
action.controls.forEach(control => {
invalidatedControls[control] = true;
});
return {
...state,
chartNormalization: {
...state.chartNormalization,
invalidatedControls,
},
};
}
case BEGIN_CHART_NORMALIZATION_SAVE:
if (
state.chartNormalization?.chartId !== action.chartId ||
state.chartNormalization.hydrationSessionId !==
action.hydrationSessionId
) {
return state;
}
return {
...state,
chartNormalization: {
...state.chartNormalization,
saveAttemptId: action.saveAttemptId,
},
};
case COMPLETE_CHART_NORMALIZATION_SAVE:
if (
state.chartNormalization?.chartId !== action.chartId ||
state.chartNormalization.hydrationSessionId !==
action.hydrationSessionId ||
state.chartNormalization.saveAttemptId !== action.saveAttemptId
) {
return state;
}
return {
...state,
chartNormalization: {
...state.chartNormalization,
transitions: action.transitions,
saveAttemptId: null,
},
};
default:
return state;
}
@@ -292,3 +420,6 @@ export const selectVersionLastRestoredUuid = (state: VersionHistoryRootState) =>
export const selectVersionSessionLog = (state: VersionHistoryRootState) =>
selectVersionHistory(state).sessionLog;
export const selectChartNormalization = (state: VersionHistoryRootState) =>
selectVersionHistory(state).chartNormalization;
@@ -21,6 +21,7 @@ import { versionSessionLogMiddleware } from './sessionLogMiddleware';
import {
APPEND_VERSION_SESSION_LOG,
CLEAR_VERSION_SESSION_LOG,
INVALIDATE_CHART_NORMALIZATION_CONTROLS,
} from './reducer';
jest.mock('@superset-ui/core', () => ({
@@ -77,7 +78,7 @@ test('falls back to a humanized control name when no label exists', () => {
);
});
test('skips programmatic control writes so untouched charts stay clean', async () => {
test('programmatic writes invalidate normalization without logging an edit', async () => {
// Effects rewrite controls with no user gesture (transferred-control
// cleanup after load, derived margins); logging them would report unsaved
// edits the user never made. Built with the REAL action creator so the
@@ -89,15 +90,54 @@ test('skips programmatic control writes so untouched charts stay clean', async (
explore: { controls: { metrics: { label: 'Metrics' } } },
});
run(store, setControlValue('metrics', [], undefined, { programmatic: true }));
expect(store.dispatch).not.toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith({
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
controls: ['metrics'],
});
// The same creator without the mark still logs.
store.dispatch.mockClear();
run(store, setControlValue('metrics', []));
expect(store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: APPEND_VERSION_SESSION_LOG }),
);
});
test('a write matching the hydrated default preserves normalization', async () => {
const { setControlValue } =
await import('src/explore/actions/exploreActions');
const store = buildStore({
explore: {
controls: { show_totals: { label: 'Show totals' } },
form_data: { show_totals: false },
},
versionHistory: {
chartNormalization: {
chartId: 7,
hydrationSessionId: 'hydration-a',
saveAttemptId: null,
invalidatedControls: {},
transitions: {
show_totals: {
control: 'show_totals',
from_present: false,
to_present: true,
to_value: false,
},
},
},
},
});
run(
store,
setControlValue('show_totals', false, undefined, { programmatic: true }),
);
expect(store.dispatch).not.toHaveBeenCalled();
});
test('clears the session log when the explore page hydrates', () => {
const store = buildStore();
run(store, { type: 'HYDRATE_EXPLORE', data: {} });
@@ -188,7 +228,7 @@ test('a history step cannot collapse into an adjacent control entry', async () =
await import('src/explore/actions/exploreActions');
const store = buildStore({ explore: { controls: {} } });
run(store, setExploreControls({} as never));
const { entry } = store.dispatch.mock.calls[0][0];
const [[{ entry }]] = store.dispatch.mock.calls;
expect(entry.controlName).not.toMatch(/^[a-z]/);
});
@@ -19,7 +19,13 @@
import type { Middleware } from 'redux';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { appendVersionSessionLog, clearVersionSessionLog } from './reducer';
import {
appendVersionSessionLog,
clearVersionSessionLog,
invalidateChartNormalizationControls,
} from './reducer';
import { jsonValuesEqual } from './normalization';
import type { ChartNormalizationTrackingState } from './types';
// Action types are inlined (rather than imported from the explore
// module) so this middleware does not pull explore code into every
@@ -47,9 +53,54 @@ interface SessionLogState {
user?: { firstName?: string; lastName?: string };
explore?: {
controls?: Record<string, { label?: unknown } | undefined>;
form_data?: Record<string, unknown>;
};
versionHistory?: {
chartNormalization?: ChartNormalizationTrackingState | null;
};
}
/** Untrusted Explore action shape; fields narrow only at this boundary. */
interface ExploreBoundaryAction {
type: unknown;
controlName?: unknown;
formData?: Record<string, unknown>;
}
const changedFormDataKeys = (
before: Record<string, unknown> = {},
after: Record<string, unknown> = {},
) =>
[...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
key => before[key] !== after[key],
);
/**
* Anti-corruption adapter from Explore's action vocabulary to the stable
* versioning concept of controls whose user-intent evidence is no longer valid.
*/
export const normalizationControlsChangedByExplore = (
action: ExploreBoundaryAction,
before: Record<string, unknown> | undefined,
after: Record<string, unknown> | undefined,
) => {
if (action.type === HYDRATE_EXPLORE) {
return [];
}
const controls = changedFormDataKeys(before, after);
if (
action.type === SET_FIELD_VALUE &&
typeof action.controlName === 'string'
) {
controls.push(action.controlName);
} else if (action.type === SET_EXPLORE_CONTROLS && action.formData) {
controls.push(...Object.keys(action.formData));
} else if (action.type === UPDATE_FORM_DATA_BY_DATASOURCE) {
controls.push(DATASOURCE_CONTROL_NAME);
}
return [...new Set(controls)];
};
function controlLabel(state: SessionLogState, controlName: string): string {
const label = state.explore?.controls?.[controlName]?.label;
return typeof label === 'string' && label
@@ -64,6 +115,25 @@ function userName(state: SessionLogState): string | null {
return name || null;
}
const normalizationControlsNoLongerMatching = (
controls: string[],
state: SessionLogState,
) => {
const formData = state.explore?.form_data ?? {};
const transitions = state.versionHistory?.chartNormalization?.transitions;
return controls.filter(control => {
const transition = transitions?.[control];
if (!transition) {
return true;
}
const present = Object.hasOwn(formData, control);
return (
present !== transition.to_present ||
(present && !jsonValuesEqual(formData[control], transition.to_value))
);
});
};
/**
* Records unsaved explore control changes in the version history
* session log ("Current version" section) and resets the log whenever
@@ -71,6 +141,7 @@ function userName(state: SessionLogState): string | null {
*/
export const versionSessionLogMiddleware: Middleware =
store => next => action => {
const before = (store.getState() as SessionLogState).explore?.form_data;
const result = next(action);
if (!isFeatureEnabled(FeatureFlag.VersionHistory)) {
return result;
@@ -151,5 +222,17 @@ export const versionSessionLogMiddleware: Middleware =
}),
);
}
const state = store.getState() as SessionLogState;
const changedControls = normalizationControlsNoLongerMatching(
normalizationControlsChangedByExplore(
action,
before,
state.explore?.form_data,
),
state,
);
if (changedControls.length) {
store.dispatch(invalidateChartNormalizationControls(changedControls));
}
return result;
};
@@ -209,6 +209,45 @@ export interface SessionLogEntry {
user: string | null;
}
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
type PresentNormalizationValue<Prefix extends 'from' | 'to'> =
Prefix extends 'from'
? { from_present: true; from_value: JsonValue }
: { to_present: true; to_value: JsonValue };
type MissingNormalizationValue<Prefix extends 'from' | 'to'> =
Prefix extends 'from'
? { from_present: false; from_value?: never }
: { to_present: false; to_value?: never };
/** One guarded hydration transition sent with an existing-chart overwrite. */
export type AutomaticNormalizationTransition = { control: string } & (
| PresentNormalizationValue<'from'>
| MissingNormalizationValue<'from'>
) &
(PresentNormalizationValue<'to'> | MissingNormalizationValue<'to'>);
export type AutomaticNormalizationTransitions = Record<
string,
AutomaticNormalizationTransition
>;
/** Identity-bound state for one chart hydration and its in-flight save. */
export interface ChartNormalizationTrackingState {
chartId: number;
hydrationSessionId: string;
transitions: AutomaticNormalizationTransitions;
invalidatedControls: Record<string, true>;
saveAttemptId: string | null;
}
export interface VersionHistoryState {
isPanelOpen: boolean;
entityType: VersionedEntityType | null;
@@ -228,4 +267,6 @@ export interface VersionHistoryState {
* the one their page shows.
*/
lastRestoredEntityUuid: string | null;
/** Advisory transitions for the active Explore chart hydration. */
chartNormalization?: ChartNormalizationTrackingState | null;
}
@@ -383,3 +383,223 @@ describe('ChartList - Global Filter Interactions', () => {
expect((searchInput as HTMLInputElement).value).toBe('');
});
});
// The blocking-alerts/reports pre-flight in the Archive modal (sc-117151).
// Each test registers its report-API route BEFORE setupMocks so it takes
// precedence over the catch-all route.
const adminChartUser = { ...mockUser, username: 'admin', permissions: {} };
const openFirstDeleteModal = async () => {
// ALERT_REPORTS must be on for the pre-flight to fire at all — with it off
// the modal opens synchronously with no dependency fetch (see the flag-off
// test below).
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockImplementation(
(feature: string) =>
feature === 'SOFT_DELETE' || feature === 'ALERT_REPORTS',
);
renderChartList(adminChartUser);
await screen.findByTestId('chart-list-view');
const deleteButtons = await screen.findAllByTestId('chart-row-delete');
fireEvent.click(deleteButtons[0]);
return screen.findByRole('dialog');
};
test('archive modal lists the blocking alerts and reports with their types', async () => {
fetchMock.removeRoutes();
fetchMock.get('glob:*/api/v1/report/*', {
count: 2,
result: [
{ id: 1, name: 'TC-081 rerun report', type: 'Report' },
{ id: 2, name: 'Threshold alert', type: 'Alert' },
],
});
setupMocks();
try {
const dialog = await openFirstDeleteModal();
expect(
within(dialog).getByText('Associated alerts and reports'),
).toBeInTheDocument();
expect(within(dialog).getByText('TC-081 rerun report')).toBeInTheDocument();
expect(within(dialog).getByText('Threshold alert')).toBeInTheDocument();
expect(within(dialog).getByText('Report')).toBeInTheDocument();
expect(within(dialog).getByText('Alert')).toBeInTheDocument();
// Advisory only: the Archive button stays enabled.
expect(
within(dialog).getByRole('button', { name: 'Archive' }),
).toBeEnabled();
} finally {
fetchMock.clearHistory();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockReset();
}
});
test('archive modal is unchanged when the chart has no alerts or reports', async () => {
fetchMock.removeRoutes();
fetchMock.get('glob:*/api/v1/report/*', { count: 0, result: [] });
setupMocks();
try {
const dialog = await openFirstDeleteModal();
expect(
within(dialog).getByText(/moved to Recently Archived/i),
).toBeInTheDocument();
expect(
within(dialog).queryByText('Associated alerts and reports'),
).not.toBeInTheDocument();
} finally {
fetchMock.clearHistory();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockReset();
}
});
test('archive modal opens unchanged and confirm still deletes when the report API 404s', async () => {
fetchMock.removeRoutes();
fetchMock.get('glob:*/api/v1/report/*', 404);
fetchMock.delete(`glob:*/api/v1/chart/${mockCharts[0].id}`, {});
setupMocks();
try {
const dialog = await openFirstDeleteModal();
expect(
within(dialog).queryByText('Associated alerts and reports'),
).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Archive' }));
await waitFor(() =>
expect(
fetchMock.callHistory.calls(`glob:*/api/v1/chart/${mockCharts[0].id}`),
).toHaveLength(1),
);
} finally {
fetchMock.clearHistory();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockReset();
}
});
test('archive modal caps the list at ten and reports the overflow count', async () => {
fetchMock.removeRoutes();
fetchMock.get('glob:*/api/v1/report/*', {
count: 12,
result: Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
name: `Blocking report ${i + 1}`,
type: 'Report',
})),
});
setupMocks();
try {
const dialog = await openFirstDeleteModal();
expect(within(dialog).getByText('Blocking report 10')).toBeInTheDocument();
expect(within(dialog).getByText('... and 2 more')).toBeInTheDocument();
} finally {
fetchMock.clearHistory();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockReset();
}
});
test('archive modal refetches on every open so the list stays fresh', async () => {
fetchMock.removeRoutes();
fetchMock.get(
'glob:*/api/v1/report/*',
{
count: 1,
result: [{ id: 1, name: 'Detach me first', type: 'Report' }],
},
{ name: 'blocking-reports' },
);
setupMocks();
try {
const dialog = await openFirstDeleteModal();
expect(within(dialog).getByText('Detach me first')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
// The user detaches the report; the next open must show the new truth.
fetchMock.removeRoute('blocking-reports');
fetchMock.get(
'glob:*/api/v1/report/*',
{ count: 0, result: [] },
{ name: 'blocking-reports-empty' },
);
const deleteButtons = await screen.findAllByTestId('chart-row-delete');
fireEvent.click(deleteButtons[0]);
const reopened = await screen.findByRole('dialog');
expect(
within(reopened).queryByText('Detach me first'),
).not.toBeInTheDocument();
expect(
within(reopened).queryByText('Associated alerts and reports'),
).not.toBeInTheDocument();
} finally {
fetchMock.clearHistory();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockReset();
}
});
test('archive modal opens without any report fetch when ALERT_REPORTS is off', async () => {
fetchMock.removeRoutes();
fetchMock.get(
'glob:*/api/v1/report/*',
{
count: 1,
result: [{ id: 1, name: 'Should not appear', type: 'Report' }],
},
{ name: 'reports-should-not-be-called' },
);
setupMocks();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockImplementation((feature: string) => feature === 'SOFT_DELETE');
try {
renderChartList(adminChartUser);
await screen.findByTestId('chart-list-view');
const deleteButtons = await screen.findAllByTestId('chart-row-delete');
fireEvent.click(deleteButtons[0]);
const dialog = await screen.findByRole('dialog');
expect(
within(dialog).queryByText('Associated alerts and reports'),
).not.toBeInTheDocument();
expect(
fetchMock.callHistory.calls('reports-should-not-be-called'),
).toHaveLength(0);
} finally {
fetchMock.clearHistory();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockReset();
}
});
test('delete confirmation keeps the type-DELETE gate when SOFT_DELETE is off', async () => {
fetchMock.removeRoutes();
fetchMock.get('glob:*/api/v1/report/*', { count: 0, result: [] });
setupMocks();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockImplementation((feature: string) => feature === 'ALERT_REPORTS');
try {
renderChartList(adminChartUser);
await screen.findByTestId('chart-list-view');
const deleteButtons = await screen.findAllByTestId('chart-row-delete');
fireEvent.click(deleteButtons[0]);
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Please confirm')).toBeInTheDocument();
expect(screen.getByTestId('delete-modal-input')).toBeInTheDocument();
} finally {
fetchMock.clearHistory();
(
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
).mockReset();
}
});
+175 -49
View File
@@ -17,7 +17,7 @@
* under the License.
*/
import { SupersetTheme, css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { t, tn } from '@apache-superset/core/translation';
import {
isFeatureEnabled,
FeatureFlag,
@@ -26,7 +26,7 @@ import {
SupersetClient,
isMatrixifyEnabled,
} from '@superset-ui/core';
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useRef } from 'react';
import rison from 'rison';
import { uniqBy } from 'lodash-es';
import { useSelector } from 'react-redux';
@@ -53,6 +53,8 @@ import {
ActionButton,
ConfirmStatusChange,
CertifiedBadge,
DeleteModal,
List,
Tooltip,
FaveStar,
InfoTooltip,
@@ -111,6 +113,77 @@ const FlexRowContainer = styled.div`
`;
const PAGE_SIZE = 25;
// How many blocking alerts/reports the archive modal previews before the
// "... and N more" overflow line (dataset-modal parity).
const BLOCKING_REPORTS_PREVIEW_SIZE = 10;
interface BlockingReport {
id: number;
name: string;
type: 'Alert' | 'Report';
}
interface ChartDeleteState {
chart: Chart;
blockingReports: BlockingReport[];
blockingReportsCount: number;
}
function ChartArchiveDescription({
chart,
blockingReports,
blockingReportsCount,
softDelete,
}: ChartDeleteState & { softDelete: boolean }) {
const overflowCount = blockingReportsCount - blockingReports.length;
return (
<>
{softDelete ? (
<p>{archiveConfirmDescription(t('chart'))}</p>
) : (
<p>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>?
</p>
)}
{blockingReports.length > 0 && (
<>
<h4>{t('Associated alerts and reports')}</h4>
<p>
{t(
'Archiving or deleting this chart will be blocked while the following alerts or reports use it. Detach or delete them first.',
)}
</p>
<List
split={false}
size="small"
dataSource={blockingReports}
renderItem={(report: BlockingReport) => (
<List.Item key={report.id} compact>
<List.Item.Meta
avatar={<span aria-hidden="true"></span>}
title={report.name}
description={
report.type === 'Alert' ? t('Alert') : t('Report')
}
/>
</List.Item>
)}
/>
{overflowCount > 0 && (
<p>
{tn(
'... and %s more',
'... and %s more',
overflowCount,
overflowCount,
)}
</p>
)}
</>
)}
</>
);
}
const PASSWORDS_NEEDED_MESSAGE = t(
'The passwords for the databases below are needed in order to ' +
'import them together with the charts. Please note that the ' +
@@ -211,6 +284,11 @@ function ChartList(props: ChartListProps) {
} = useChartEditModal(setCharts, charts);
const [importingChart, showImportModal] = useState<boolean>(false);
const [chartCurrentlyDeleting, setChartCurrentlyDeleting] =
useState<ChartDeleteState | null>(null);
// Monotonic token: a late pre-flight response for an earlier click must not
// swap the modal to a different chart (last-response-wins race).
const deleteModalRequestRef = useRef(0);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
const [sshTunnelPasswordFields, setSSHTunnelPasswordFields] = useState<
@@ -274,6 +352,51 @@ function ChartList(props: ChartListProps) {
[addDangerToast],
);
const openChartDeleteModal = useCallback((chart: Chart) => {
deleteModalRequestRef.current += 1;
const requestToken = deleteModalRequestRef.current;
if (!isFeatureEnabled(FeatureFlag.AlertReports)) {
// The whole report API 404s when ALERT_REPORTS is off, while the delete
// guard still fires server-side. Skip the doomed request and open the
// unchanged modal immediately.
setChartCurrentlyDeleting({
chart,
blockingReports: [],
blockingReportsCount: 0,
});
return;
}
const queryParams = rison.encode({
filters: [{ col: 'chart_id', opr: 'eq', value: chart.id }],
columns: ['id', 'name', 'type'],
order_column: 'name',
order_direction: 'asc',
page_size: BLOCKING_REPORTS_PREVIEW_SIZE,
});
SupersetClient.get({ endpoint: `/api/v1/report/?q=${queryParams}` })
.then(({ json = {} }) => {
if (requestToken !== deleteModalRequestRef.current) return;
const blockingReports: BlockingReport[] = json.result ?? [];
setChartCurrentlyDeleting({
chart,
blockingReports,
blockingReportsCount: json.count ?? blockingReports.length,
});
})
.catch(() => {
if (requestToken !== deleteModalRequestRef.current) return;
// The report API can be visibility-filtered below what the delete
// guard sees, or fail outright. The list is advisory only, so every
// failure opens the unchanged modal rather than blocking the action;
// the confirm-time guard stays authoritative.
setChartCurrentlyDeleting({
chart,
blockingReports: [],
blockingReportsCount: 0,
});
});
}, []);
function handleBulkChartDelete(chartsToDelete: Chart[]) {
SupersetClient.delete({
endpoint: `/api/v1/chart/?q=${rison.encode(
@@ -527,14 +650,11 @@ function ChartList(props: ChartListProps) {
},
{
Cell: ({ row: { original } }: CellProps<Chart>) => {
const allowEdit = isUserEditorOrAdmin(user, original.editors);
const handleDelete = () =>
handleChartDelete(
original,
addSuccessToast,
addDangerToast,
refreshData,
);
const allowEdit = isUserEditorOrAdmin(
user,
original.editors,
original.extra_editors,
);
const openEditModal = () => openChartEditModal(original);
const handleExport = () => handleBulkChartExport([original]);
if (!canEdit && !canDelete && !canExport) {
@@ -573,43 +693,21 @@ function ChartList(props: ChartListProps) {
/>
)}
{canDelete && (
<ConfirmStatusChange
recoverable={softDelete}
title={
softDelete
? t('Archive %(name)s?', { name: original.slice_name })
: t('Please confirm')
<ActionButton
label={deleteActionLabel()}
tooltip={
allowEdit
? deleteActionLabel()
: t(
'You must be a chart editor in order to delete. Please reach out to a chart editor to request modifications or edit access.',
)
}
description={
softDelete ? (
archiveConfirmDescription(t('chart'))
) : (
<>
{t('Are you sure you want to delete')}{' '}
<b>{original.slice_name}</b>?
</>
)
}
onConfirm={handleDelete}
>
{confirmDelete => (
<ActionButton
label={deleteActionLabel()}
tooltip={
allowEdit
? deleteActionLabel()
: t(
'You must be a chart editor in order to delete. Please reach out to a chart editor to request modifications or edit access.',
)
}
placement="bottom"
icon={<Icons.DeleteOutlined iconSize="l" />}
dataTest="chart-row-delete"
disabled={!allowEdit}
onClick={confirmDelete}
/>
)}
</ConfirmStatusChange>
placement="bottom"
icon={<Icons.DeleteOutlined iconSize="l" />}
dataTest="chart-row-delete"
disabled={!allowEdit}
onClick={() => openChartDeleteModal(original)}
/>
)}
</Actions>
);
@@ -634,11 +732,9 @@ function ChartList(props: ChartListProps) {
canExport,
saveFavoriteStatus,
favoriteStatus,
refreshData,
addSuccessToast,
addDangerToast,
handleBulkChartExport,
openChartEditModal,
openChartDeleteModal,
],
);
@@ -933,6 +1029,36 @@ function ChartList(props: ChartListProps) {
slice={sliceCurrentlyEditing}
/>
)}
{chartCurrentlyDeleting && (
<DeleteModal
recoverable={softDelete}
title={
softDelete
? t('Archive %(name)s?', {
name: chartCurrentlyDeleting.chart.slice_name,
})
: t('Please confirm')
}
name={chartCurrentlyDeleting.chart.slice_name}
open
description={
<ChartArchiveDescription
{...chartCurrentlyDeleting}
softDelete={softDelete}
/>
}
onConfirm={() => {
handleChartDelete(
chartCurrentlyDeleting.chart,
addSuccessToast,
addDangerToast,
refreshData,
);
setChartCurrentlyDeleting(null);
}}
onHide={() => setChartCurrentlyDeleting(null)}
/>
)}
<ConfirmStatusChange
recoverable={softDelete}
title={softDelete ? t('Archive selected charts?') : t('Please confirm')}
@@ -122,6 +122,8 @@ export interface Dashboard {
description?: string;
thumbnail_url?: string | null;
editors?: Subject[];
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
extra_editors?: number[];
viewers?: Subject[];
tags: TagType[];
created_by: object;
@@ -505,7 +507,11 @@ function DashboardList(props: DashboardListProps) {
},
{
Cell: ({ row: { original } }: CellProps<Dashboard>) => {
const allowEdit = isUserEditorOrAdmin(user, original.editors);
const allowEdit = isUserEditorOrAdmin(
user,
original.editors,
original.extra_editors,
);
const handleDelete = () =>
handleDashboardDelete(
original,
@@ -0,0 +1,66 @@
/**
* 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 { MemoryRouter } from 'react-router-dom';
import { render, screen } from 'spec/helpers/testing-library';
import { LocationProvider, useLocationState } from './LocationContext';
const Probe = () => {
const { requestedQuery } = useLocationState();
return (
<>
<span data-test="autorun">{String(requestedQuery?.autorun)}</span>
<span data-test="sql">{String(requestedQuery?.sql)}</span>
</>
);
};
const setup = (initialEntry: string | { pathname: string; state: object }) =>
render(
<MemoryRouter initialEntries={[initialEntry]}>
<LocationProvider>
<Probe />
</LocationProvider>
</MemoryRouter>,
);
test('never derives autorun from the URL querystring', () => {
// Regression test: a crafted cross-site GET link must not be able to
// auto-execute SQL in the victim's session (top-level navigation sends
// SameSite=Lax session cookies).
setup('/sqllab?dbid=1&sql=SELECT%20%2A%20FROM%20t&autorun=true');
expect(screen.getByTestId('autorun')).toHaveTextContent('false');
// The deep link still prefills the editor with the requested SQL.
expect(screen.getByTestId('sql')).toHaveTextContent('SELECT * FROM t');
});
test('querystring autorun stays false even when spread from raw params', () => {
// `...Object.fromEntries(queryParams)` must not reintroduce the raw
// `autorun` string value.
setup('/sqllab?sql=SELECT%201&autorun=true');
expect(screen.getByTestId('autorun')).toHaveTextContent('false');
});
test('honors autorun from in-app location.state navigations', () => {
setup({
pathname: '/sqllab',
state: { requestedQuery: { sql: 'SELECT 1', autorun: true } },
});
expect(screen.getByTestId('autorun')).toHaveTextContent('true');
});
@@ -41,12 +41,14 @@ export const LocationProvider: FC<{ children?: ReactNode }> = ({
const queryParams = new URLSearchParams(location.search);
const permalink = location.pathname.match(/\/p\/\w+/)?.[0].slice(3);
if (queryParams.size > 0 || permalink) {
const autorun = queryParams.get('autorun') === 'true';
// Deep links (querystring or permalink) prefill the editor and wait
// for the user to press Run. Only in-app navigations that pass
// `location.state` (handled above) may request autorun.
const isDataset = queryParams.get('isDataset') === 'true';
const queryParamsState = {
requestedQuery: {
...Object.fromEntries(queryParams),
autorun,
autorun: false,
permalink,
},
isDataset,
+2
View File
@@ -45,6 +45,8 @@ export interface Chart {
cache_timeout: number | null;
thumbnail_url?: string;
editors?: Subject[];
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
extra_editors?: number[];
viewers?: Subject[];
tags?: TagType[];
last_saved_at?: string;
@@ -67,6 +67,8 @@ export interface Dashboard {
url: string;
thumbnail_url?: string | null;
editors?: Subject[];
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
extra_editors?: number[];
viewers?: Subject[];
loading?: boolean;
}
@@ -17,8 +17,12 @@
* under the License.
*/
import rison from 'rison';
import { waitFor } from '@testing-library/react';
import { SupersetClient } from '@superset-ui/core';
import Chart from 'src/types/Chart';
import {
checkUploadExtensions,
handleChartDelete,
getAlreadyExists,
getEncryptedExtraFieldsNeeded,
getFilterValues,
@@ -743,3 +747,27 @@ test('getFilterValues', () => {
);
});
});
test('handleChartDelete surfaces the blocking alert/report names from a 422', async () => {
const guardMessage =
'There are associated alerts or reports: TC-081 rerun report';
const deleteSpy = jest
.spyOn(SupersetClient, 'delete')
.mockRejectedValue(
new Response(JSON.stringify({ message: guardMessage }), { status: 422 }),
);
const addDangerToast = jest.fn();
try {
handleChartDelete(
{ id: 1, slice_name: 'blocked chart' } as Chart,
jest.fn(),
addDangerToast,
jest.fn(),
);
await waitFor(() => expect(addDangerToast).toHaveBeenCalledTimes(1));
expect(addDangerToast.mock.calls[0][0]).toContain('TC-081 rerun report');
expect(addDangerToast.mock.calls[0][0]).toContain('blocked chart');
} finally {
deleteSpy.mockRestore();
}
});
+3 -3
View File
@@ -379,9 +379,9 @@ export function handleChartDelete(
else refreshData();
addSuccessToast(deletedToast(sliceName));
},
() => {
addDangerToast(deleteFailedToast(sliceName));
},
createErrorHandler(errMsg =>
addDangerToast(deleteFailedToast(sliceName, errMsg)),
),
);
}
@@ -140,4 +140,59 @@ describe('LeftCell', () => {
'http://example.com/sales?type=numeric&label=Sales Data',
);
});
test('should not render javascript: URLs as links for column rows', () => {
const columnRow = {
label: 'Test Column',
column_name: 'test_column',
};
render(
<LeftCell
row={columnRow}
rowType="column"
url="javascript:alert(document.domain)" // eslint-disable-line no-script-url
/>,
);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByText('Test Column')).toBeInTheDocument();
});
test('should not render script-bearing schemes assembled via templating', () => {
const columnRow = {
label: 'Test Column',
column_name: 'alert(1)',
};
render(
<LeftCell
row={columnRow}
rowType="column"
url="javascript:{{metric.column_name}}" // eslint-disable-line no-script-url
/>,
);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
test('should keep relative URLs as links', () => {
const columnRow = {
label: 'Test Column',
column_name: 'test_column',
};
render(
<LeftCell
row={columnRow}
rowType="column"
url="/superset/dashboard/{{metric.column_name}}/"
/>,
);
expect(screen.getByRole('link')).toHaveAttribute(
'href',
'/superset/dashboard/test_column/',
);
});
});
@@ -28,6 +28,23 @@ interface LeftCellProps {
url?: string;
}
/**
* Confines a caller-supplied URL to http(s) and relative schemes before
* it's rendered as a link. Returns undefined for anything else, degrading
* the cell to plain text.
*/
export const toSafeHref = (url: string): string | undefined => {
try {
const { protocol } = new URL(url, window.location.origin);
if (protocol === 'http:' || protocol === 'https:') {
return url;
}
} catch {
// fall through: unparseable URLs are not rendered as links
}
return undefined;
};
/**
* Renders the left cell containing either column labels or metric information
*/
@@ -35,7 +52,7 @@ const LeftCell = ({ row, rowType, url }: LeftCellProps): ReactElement => {
const fullUrl = useMemo(() => {
if (!url) return undefined;
const context = { metric: row };
return Mustache.render(url, context);
return toSafeHref(Mustache.render(url, context));
}, [url, row]);
if (rowType === 'column') {
+36
View File
@@ -625,6 +625,42 @@ describe('server', () => {
);
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
});
test('unsolicited pong payload cannot pollute Object.prototype', async () => {
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
const request = getRequest(validToken, 'http://localhost');
server.wsConnection(ws, request);
// Extract the handler registered for the 'pong' event, the same way
// the underlying `ws` library would invoke it on a raw pong frame.
const pongCall = wsEventMock.mock.calls.find(call => call[0] === 'pong');
expect(pongCall).toBeDefined();
const pongHandler = pongCall![1] as (data: Buffer) => void;
// An unsolicited pong with a payload matching an inherited key must not
// resolve through the prototype chain and must not write through to
// Object.prototype.
pongHandler(Buffer.from('__proto__'));
pongHandler(Buffer.from('constructor'));
pongHandler(Buffer.from('hasOwnProperty'));
// eslint-disable-next-line no-prototype-builtins
expect(Object.prototype.hasOwnProperty('pongTs')).toBe(false);
expect(({} as Record<string, unknown>).pongTs).toBeUndefined();
// A genuine socket id must still record its pong normally.
const socketId = server.channels[channelId].sockets[0];
const beforePongTs = server.sockets[socketId].pongTs;
dateNowSpy.mockImplementation(() =>
new Date('2021-03-10T11:02:58.135Z').valueOf(),
);
pongHandler(Buffer.from(socketId));
expect(server.sockets[socketId].pongTs).not.toBe(beforePongTs);
expect(server.sockets[socketId].pongTs).toBe(
new Date('2021-03-10T11:02:58.135Z').valueOf(),
);
});
});
describe('connection limits', () => {
+10 -4
View File
@@ -475,12 +475,18 @@ export const wsConnection = (ws: WebSocket, request: http.IncomingMessage) => {
// init event handler for `pong` events (connection management)
ws.on('pong', function pong(data: Buffer) {
const socketId = data.toString();
const socketInstance = sockets[socketId];
if (!socketInstance) {
// `sockets` is a plain object, so an unsolicited pong carrying an
// inherited key ('__proto__', 'constructor', 'hasOwnProperty', ...) as
// its payload would otherwise resolve through the prototype chain
// instead of missing outright, letting a client write an enumerable
// `pongTs` onto Object.prototype (tripped over by the for...in loops in
// checkSockets/cleanChannel on every GC pass). Guarding with an
// own-property check rejects every such key in one place.
if (!Object.prototype.hasOwnProperty.call(sockets, socketId)) {
logger.warn(`pong received for nonexistent socket ${socketId}`);
} else {
socketInstance.pongTs = Date.now();
return;
}
sockets[socketId].pongTs = Date.now();
});
};
+18 -11
View File
@@ -238,17 +238,24 @@ class AsyncQueryManager:
secret so the value is unguessable to outside callers.
"""
token = guest_user.guest_token
# HMAC over the complete claim set so that tokens differing in *any*
# claim derive distinct channels. Enumerating claims here is unsafe:
# omitting one that scopes the session -- most importantly
# ``rls_rules``, the primary tenant-isolation mechanism for embedded
# dashboards -- would let two tenants' tokens minted in the same
# second with identical user/resources collide on one channel,
# exposing job events (including error strings) and cross-tenant
# cancellation. ``iat`` uniquely identifies a token issuance, so it
# provides per-token isolation while remaining stable across the
# lifetime of a single embedded session.
message = json.dumps(token, sort_keys=True).encode("utf-8")
# ``iat`` uniquely identifies a guest token issuance, so it provides
# per-token isolation while remaining stable across the lifetime of a
# single embedded session.
message = json.dumps(
{
"user": token.get("user"),
"resources": token.get("resources"),
"iat": token.get("iat"),
"exp": token.get("exp"),
"aud": token.get("aud"),
# ``datasets`` and ``rev`` are optional scope claims, so tokens
# that differ only in their dataset allowlist or revocation
# version still derive distinct channels.
"datasets": token.get("datasets"),
"rev": token.get("rev"),
},
sort_keys=True,
).encode("utf-8")
digest = hmac.new(
self._jwt_secret.encode("utf-8"), message, hashlib.sha256
).hexdigest()
+26 -4
View File
@@ -92,7 +92,10 @@ from superset.exceptions import (
)
from superset.extensions import event_logger, security_manager
from superset.models.slice import Slice
from superset.security.manager import get_extra_editor_subject_ids
from superset.security.manager import (
get_extra_editor_subject_ids,
get_extra_editors_by_pk,
)
from superset.subjects.filters import (
FilterRelatedSubjects,
subject_type_filter,
@@ -410,12 +413,21 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
except ChartNotFoundError:
return self.response_404()
def pre_get_list(self, data: dict[str, Any]) -> None:
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
super().pre_get_list(data)
ids = data.get("ids", [])
extra_editors_by_id = get_extra_editors_by_pk(Slice, ids)
for row, row_id in zip(data.get("result", []), ids, strict=False):
if row_id in extra_editors_by_id:
row["extra_editors"] = extra_editors_by_id[row_id]
@expose("/<pk>/deck_layers/", methods=("GET",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: (f"{self.__class__.__name__}.deck_layers"),
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.deck_layers",
log_to_statsd=False,
)
def deck_layers(self, pk: int) -> Response:
@@ -679,13 +691,17 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
except ValidationError as error:
return self.response_400(message=error.messages)
normalization_changes: object = item.pop("normalization_changes", None)
# Live version identifiers before the update (empty + query-free when
# ``ENABLE_VERSIONING_CAPTURE`` is off, so this stays inert under the
# kill-switch).
old_info = current_entity_version_info(Slice, pk)
try:
changed_model = UpdateChartCommand(pk, item).run()
changed_model = UpdateChartCommand(
pk, item, normalization_changes=normalization_changes
).run()
new_info = current_entity_version_info(
Slice, changed_model.id, changed_model.uuid
)
@@ -1050,7 +1066,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
task_status=cache_payload.get_status(),
)
if cache_payload.should_trigger_task(force):
if cache_payload.should_trigger_task(force, expected_scope=f"chart:{chart.id}"):
logger.info("Triggering screenshot ASYNC")
screenshot_obj.cache.set(cache_key, ScreenshotCachePayload().to_dict())
cache_chart_thumbnail.delay(
@@ -1112,6 +1128,12 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
return self.response_404()
if cache_payload := ChartScreenshot.get_from_cache_key(digest):
# The digest is caller-supplied and cache entries are shared
# across every chart (and, via the same backend, dashboards) --
# without this check any cache_key learned for one chart would
# serve its image under a different, merely-accessible `pk`.
if cache_payload.get_scope() != f"chart:{chart.id}":
return self.response_404()
if cache_payload.status == StatusValues.UPDATED:
try:
image = cache_payload.get_image()
+24
View File
@@ -368,6 +368,30 @@ class ChartPutSchema(Schema):
external_url = fields.String(allow_none=True, validate=utils.validate_external_url)
tags = fields.List(fields.Integer(metadata={"description": tags_description}))
uuid = fields.UUID(allow_none=True)
normalization_changes: fields.Raw = fields.Raw(
load_only=True,
allow_none=True,
metadata={
"description": (
"Optional advisory Explore hydration transitions used only to "
"remove exact automatic normalization changes from human-readable "
"version history. Invalid metadata is ignored."
),
"type": "array",
"maxItems": 256,
"items": {
"type": "object",
"required": ["control", "from_present", "to_present"],
"properties": {
"control": {"type": "string", "maxLength": 256},
"from_present": {"type": "boolean"},
"from_value": {},
"to_present": {"type": "boolean"},
"to_value": {},
},
},
},
)
class ChartGetDatasourceObjectDataResponseSchema(Schema):
+56 -4
View File
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import logging
from collections import defaultdict
from functools import partial
from typing import Optional
@@ -32,11 +33,62 @@ from superset.daos.chart import ChartDAO
from superset.daos.report import ReportScheduleDAO
from superset.exceptions import SupersetSecurityException
from superset.models.slice import Slice
from superset.reports.models import ReportSchedule
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
def build_blocked_by_reports_message(
charts: list[Slice],
reports: list[ReportSchedule],
single_target: bool,
) -> str:
"""Build the user-facing message naming the alerts/reports that block deletion.
Groups the blocking reports per chart, sorted by chart name (chart id as
the tie-breaker) and then report name so the message is deterministic
across database backends, and appends the remedy sentence. When the
command targets a single chart id the group prefix is dropped in the
single-delete endpoint the surrounding toast already names the chart, and
in a one-chart bulk selection the user's own selection provides the
context. A multi-id command keeps the prefix on every group because the
bulk toast is generic, so the prefix is the only chart identification the
user gets.
"""
sentences: list[str] = []
if single_target:
names = sorted(report.name for report in reports)
sentences.append(
str(
_(
"This chart is used by alerts or reports: %(names)s.",
names=", ".join(names),
)
)
)
else:
report_names_by_chart_id: dict[int, list[str]] = defaultdict(list)
for report in reports:
report_names_by_chart_id[report.chart_id].append(report.name)
charts_by_id = {chart.id: chart for chart in charts}
for chart_id, names in sorted(
report_names_by_chart_id.items(),
key=lambda item: (charts_by_id[item[0]].slice_name or "", item[0]),
):
sentences.append(
str(
_(
'Chart "%(chart)s" is used by alerts or reports: %(names)s.',
chart=charts_by_id[chart_id].slice_name or str(chart_id),
names=", ".join(sorted(names)),
)
)
)
sentences.append(str(_("Detach or delete them first.")))
return " ".join(sentences)
class DeleteChartCommand(BaseCommand):
def __init__(self, model_ids: list[int]):
self._model_ids = model_ids
@@ -55,11 +107,11 @@ class DeleteChartCommand(BaseCommand):
raise ChartNotFoundError()
# Check there are no associated ReportSchedules
if reports := ReportScheduleDAO.find_by_chart_ids(self._model_ids):
report_names = [report.name for report in reports]
raise ChartDeleteFailedReportsExistError(
_(
"There are associated alerts or reports: %(report_names)s",
report_names=",".join(report_names),
build_blocked_by_reports_message(
self._models,
reports,
single_target=len(self._model_ids) == 1,
)
)
# Check editorship
+22 -3
View File
@@ -44,11 +44,15 @@ from superset.commands.utils import (
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.exceptions import SupersetSecurityException
from superset.extensions import db
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import ObjectType
from superset.utils import json
from superset.utils.decorators import on_error, transaction
from superset.versioning.changes.normalization import (
register_matching_normalization_context,
)
logger = logging.getLogger(__name__)
@@ -60,10 +64,16 @@ def is_query_context_update(properties: dict[str, Any]) -> bool:
class UpdateChartCommand(UpdateMixin, BaseCommand):
def __init__(self, model_id: int, data: dict[str, Any]):
self._model_id = model_id
self._properties = data.copy()
def __init__(
self,
model_id: int,
data: dict[str, Any],
normalization_changes: object = None,
) -> None:
self._model_id: int = model_id
self._properties: dict[str, Any] = data.copy()
self._model: Optional[Slice] = None
self._normalization_changes: object = normalization_changes
@transaction(on_error=partial(on_error, reraise=ChartUpdateFailedError))
def run(self) -> Model:
@@ -78,6 +88,15 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
self._properties["last_saved_at"] = datetime.now()
self._properties["last_saved_by"] = g.user
if self._normalization_changes is not None and "params" in self._properties:
register_matching_normalization_context(
db.session,
self._model.id,
self._normalization_changes,
self._model.params,
self._properties["params"],
)
return ChartDAO.update(self._model, self._properties)
def _validate_new_dashboard_access(
@@ -21,17 +21,21 @@ from typing import Any
from marshmallow.exceptions import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.dashboard.importers import v0, v1
from superset.commands.dashboard.importers import v1
from superset.commands.exceptions import CommandInvalidError
from superset.commands.importers.exceptions import IncorrectVersionError
logger = logging.getLogger(__name__)
# list of different import formats supported; v0 should be last because
# the files are not versioned
# list of different import formats supported. The legacy v0 importer is
# deliberately NOT dispatched here: it overrides datasets matched by
# (table_name, schema, database) and charts/dashboards matched by remote_id
# without ownership checks, and this dispatcher is reachable from the HTTP
# import endpoint (POST /api/v1/dashboard/import/). Operators can still
# import legacy v0 JSON files with the `legacy_import_dashboards` CLI
# command, which uses the v0 command directly.
command_versions = [
v1.ImportDashboardsCommand,
v0.ImportDashboardsCommand,
]
@@ -21,8 +21,10 @@ from typing import Any
from flask import current_app as app
from superset import db, security_manager
from superset.commands.database.exceptions import DatabaseInvalidError
from superset.commands.database.utils import add_permissions
from superset.commands.exceptions import ImportFailedError
from superset.constants import PASSWORD_MASK
from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.databases.utils import make_url_safe
from superset.db_engine_specs.exceptions import SupersetDBAPIConnectionError
@@ -37,6 +39,73 @@ from superset.utils import json
logger = logging.getLogger(__name__)
def _connection_identity_changed(existing: Database, config: dict[str, Any]) -> bool:
"""Whether the import points the database at a different endpoint."""
try:
stored = make_url_safe(existing.sqlalchemy_uri)._replace(password=None)
incoming = make_url_safe(config["sqlalchemy_uri"])._replace(password=None)
except DatabaseInvalidError:
# An unparseable URI cannot be compared: treat it as a change so
# stored secrets never survive onto it.
return True
return stored != incoming
def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> None:
"""
Refuse an overwrite that changes the connection endpoint without fresh
credentials.
Database UUIDs are not secrets -- they appear in every exported bundle --
so an import must not be able to repoint an existing connection at a new
host while the stored password (or SSH tunnel key) is silently kept: the
next connection would hand the real credential to the new endpoint.
"""
if _connection_identity_changed(existing, config):
try:
uri_password = make_url_safe(config["sqlalchemy_uri"]).password
except DatabaseInvalidError:
uri_password = None
if config.get("password") in (None, PASSWORD_MASK) and uri_password in (
None,
PASSWORD_MASK,
):
raise ImportFailedError(
f"Import would change the connection of database "
f"'{existing.database_name}' without providing new "
"credentials. Re-enter the database password for the new "
"connection to confirm the change."
)
if ssh_tunnel := config.get("ssh_tunnel"):
existing_tunnel = existing.ssh_tunnel
if existing_tunnel and (
ssh_tunnel.get("server_address") != existing_tunnel.server_address
or ssh_tunnel.get("server_port") != existing_tunnel.server_port
):
has_fresh_credential = any(
ssh_tunnel.get(field) not in (None, PASSWORD_MASK)
for field in ("password", "private_key")
)
# A passphrase-protected private key's stored passphrase is a
# secret in its own right: if the existing tunnel had one, a
# repoint that supplies a fresh private_key but leaves
# private_key_password masked/absent would keep the old
# passphrase attached to the new key rather than requiring the
# importer to confirm it too.
stale_private_key_password = (
existing_tunnel.private_key_password is not None
and ssh_tunnel.get("private_key_password") in (None, PASSWORD_MASK)
)
if not has_fresh_credential or stale_private_key_password:
raise ImportFailedError(
f"Import would change the SSH tunnel endpoint of database "
f"'{existing.database_name}' without providing new tunnel "
"credentials. Re-enter the SSH tunnel credentials to "
"confirm the change."
)
def import_database( # noqa: C901
config: dict[str, Any],
overwrite: bool = False,
@@ -51,6 +120,11 @@ def import_database( # noqa: C901
if not overwrite or not can_write:
return existing
config["id"] = existing.id
# Stored secrets must not be rebound to a different endpoint: without
# fresh credentials, an overwrite that changes where the database (or
# its SSH tunnel) connects would exfiltrate the stored secret to the
# new endpoint on the next connection.
_refuse_stored_secret_reuse(existing, config)
elif not can_write:
raise ImportFailedError(
"Database doesn't exist and user doesn't have permission to create databases" # noqa: E501
@@ -81,7 +155,13 @@ def import_database( # noqa: C901
# For existing DBs, reveal masked sensitive values from current encrypted_extra.
# For new DBs, schema validation already ensured no fields are still masked.
if masked_encrypted_extra := config.pop("masked_encrypted_extra", None):
if existing and existing.encrypted_extra:
# Never reveal stored encrypted_extra secrets into a config that
# repoints the connection at a different endpoint.
if (
existing
and existing.encrypted_extra
and not _connection_identity_changed(existing, config)
):
old_config = json.loads(existing.encrypted_extra)
new_config = json.loads(masked_encrypted_extra)
sensitive_fields = (
@@ -21,17 +21,21 @@ from typing import Any
from marshmallow.exceptions import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.dataset.importers import v0, v1
from superset.commands.dataset.importers import v1
from superset.commands.exceptions import CommandInvalidError
from superset.commands.importers.exceptions import IncorrectVersionError
logger = logging.getLogger(__name__)
# list of different import formats supported; v0 should be last because
# the files are not versioned
# list of different import formats supported. The legacy v0 importer is
# deliberately NOT dispatched here: it overrides datasets matched by
# (table_name, schema, database) without ownership checks, and this
# dispatcher is reachable from the HTTP import endpoint
# (POST /api/v1/dataset/import/). Operators can still import legacy v0
# YAML files with the `legacy_import_datasources` CLI command, which uses
# the v0 command directly.
command_versions = [
v1.ImportDatasetsCommand,
v0.ImportDatasetsCommand,
]
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import gzip
import io
import ipaddress
import logging
import os
@@ -611,6 +612,29 @@ def _convert_temporal_columns(df: pd.DataFrame, dtype: dict[str, Any]) -> None:
df[column_name] = converted
def _read_bounded(stream: Any, max_bytes: int) -> io.BytesIO:
"""
Read ``stream`` into memory, failing once more than ``max_bytes`` bytes
have been produced.
Bounds both the raw download and gzip decompression amplification for
dataset data URIs: the ``.gz`` path had no analogue of
``check_is_safe_zip`` and allowed unbounded expansion from a small
payload.
"""
buffer = io.BytesIO()
while chunk := stream.read(1024 * 1024):
# Both http.client responses and gzip.open() yield bytes; a handful
# of tests substitute a text stream, so normalize either shape.
if isinstance(chunk, str):
chunk = chunk.encode("utf-8")
buffer.write(chunk)
if buffer.tell() > max_bytes:
raise ImportFailedError("Data URI payload exceeds the maximum allowed size")
buffer.seek(0)
return buffer
def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
"""
Load data from a data URI into a dataset.
@@ -637,9 +661,15 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
handlers.extend([_PeerValidatingHTTPHandler, _PeerValidatingHTTPSHandler])
opener = request.build_opener(*handlers)
data = opener.open(data_uri) # pylint: disable=consider-using-with # noqa: S310
# Cap the bytes materialized from the download, before and after gzip
# decompression (same per-file knob as ZIP bundle uploads): a gzip
# stream can carry oversized headers, trailing data, or additional
# members that would otherwise let the raw (compressed) download exceed
# the limit even when the decompressed CSV stays within it.
max_bytes = app.config["ZIPPED_FILE_MAX_SIZE"]
if data_uri.endswith(".gz"):
data = gzip.open(data)
df = pd.read_csv(data, encoding="utf-8")
data = gzip.open(_read_bounded(data, max_bytes))
df = pd.read_csv(_read_bounded(data, max_bytes), encoding="utf-8")
dtype = get_dtype(df, dataset)
_convert_temporal_columns(df, dtype)
+81 -4
View File
@@ -27,6 +27,7 @@ from sqlalchemy.orm import Session
from superset import db
from superset.commands.importers.exceptions import IncorrectVersionError
from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.databases.utils import make_url_safe
from superset.extensions import feature_flag_manager
from superset.models.core import Database
from superset.models.dashboard import dashboard_slices
@@ -103,6 +104,34 @@ def validate_metadata_type(
exceptions.append(exc)
def database_connection_identity_unchanged(
stored_uri: Optional[str], incoming_uri: Optional[str]
) -> bool:
"""
Whether an incoming database config still points at the same connection
(driver, host, port -- everything except the credential) as the stored one.
Stored secrets may only be re-attached to an import when this holds:
database UUIDs are not secrets (they appear in every exported bundle and
in API responses), so re-attaching secrets on a UUID match alone would
let a hostile bundle repoint an existing connection at an
attacker-controlled server that then receives the victim's real
credentials.
"""
if not stored_uri or not incoming_uri:
return False
try:
stored = make_url_safe(stored_uri)._replace(password=None)
incoming = make_url_safe(incoming_uri)._replace(password=None)
except Exception: # pylint: disable=broad-except
# An unparseable URI cannot be compared; never attach secrets to it.
return False
# Compare the full URL minus the credential, not just host/port: query
# arguments become driver connect args and can themselves redirect the
# connection (e.g. ``?host=`` for postgres drivers).
return stored == incoming
# pylint: disable=too-many-locals,too-many-arguments
# ruff: noqa: C901
def load_configs(
@@ -141,6 +170,21 @@ def load_configs(
SSHTunnel.uuid, SSHTunnel.private_key_password
).all()
}
# load connection endpoints so stored secrets are only re-attached to a
# config that still points at the same endpoint (see
# database_connection_identity_unchanged)
db_sqlalchemy_uris: dict[str, str] = {
str(uuid): sqlalchemy_uri
for uuid, sqlalchemy_uri in db.session.query(
Database.uuid, Database.sqlalchemy_uri
).all()
}
db_ssh_tunnel_servers: dict[str, tuple[Any, Any]] = {
str(uuid): (server_address, server_port)
for uuid, server_address, server_port in db.session.query(
SSHTunnel.uuid, SSHTunnel.server_address, SSHTunnel.server_port
).all()
}
for file_name, content in contents.items():
# skip directories
if not content:
@@ -151,6 +195,38 @@ def load_configs(
if schema:
try:
config = load_yaml(file_name, content)
if not isinstance(config, dict):
# A syntactically valid YAML document whose top-level
# value is a scalar or list (not a mapping) has no
# fields to validate against the schema; report it the
# same way as unparseable YAML instead of letting the
# ``.get()`` calls below raise an unhandled AttributeError.
raise ValidationError({file_name: "Not a valid YAML file"})
# Stored secrets are only reusable when the incoming config
# still points at the same endpoint as the stored one; a UUID
# match alone must never rebind stored credentials to a new
# host (see database_connection_identity_unchanged).
db_secrets_reusable = (
prefix == "databases"
and database_connection_identity_unchanged(
db_sqlalchemy_uris.get(str(config.get("uuid"))),
config.get("sqlalchemy_uri"),
)
)
incoming_tunnel = config.get("ssh_tunnel") or {}
stored_tunnel_server = db_ssh_tunnel_servers.get(
str(config.get("uuid"))
)
tunnel_secrets_reusable = (
prefix == "databases"
and stored_tunnel_server is not None
and (
incoming_tunnel.get("server_address"),
incoming_tunnel.get("server_port"),
)
== stored_tunnel_server
)
# populate passwords from the request, from YAML config,
# or from existing DBs
@@ -159,14 +235,15 @@ def load_configs(
elif prefix == "databases" and config.get("password"):
# password already in YAML config, keep it
pass
elif prefix == "databases" and config["uuid"] in db_passwords:
elif db_secrets_reusable and config["uuid"] in db_passwords:
config["password"] = db_passwords[config["uuid"]]
# populate ssh_tunnel_passwords from the request or from existing DBs
if file_name in ssh_tunnel_passwords:
config["ssh_tunnel"]["password"] = ssh_tunnel_passwords[file_name]
elif (
prefix == "databases" and config["uuid"] in db_ssh_tunnel_passwords
tunnel_secrets_reusable
and config["uuid"] in db_ssh_tunnel_passwords
):
config["ssh_tunnel"]["password"] = db_ssh_tunnel_passwords[
config["uuid"]
@@ -178,7 +255,7 @@ def load_configs(
file_name
]
elif (
prefix == "databases"
tunnel_secrets_reusable
and config["uuid"] in db_ssh_tunnel_private_keys
):
config["ssh_tunnel"]["private_key"] = db_ssh_tunnel_private_keys[
@@ -191,7 +268,7 @@ def load_configs(
ssh_tunnel_priv_key_passwords[file_name]
)
elif (
prefix == "databases"
tunnel_secrets_reusable
and config["uuid"] in db_ssh_tunnel_priv_key_passws
):
config["ssh_tunnel"]["private_key_password"] = (
+39 -3
View File
@@ -17,16 +17,52 @@
from typing import Any
from superset import db
from superset import db, security_manager
from superset.commands.exceptions import ImportFailedError
from superset.models.sql_lab import SavedQuery
from superset.utils.core import get_user
def import_saved_query(config: dict[str, Any], overwrite: bool = False) -> SavedQuery:
def import_saved_query(
config: dict[str, Any],
overwrite: bool = False,
ignore_permissions: bool = False,
) -> SavedQuery:
"""Import a saved query from a config dict, handling existing matches.
A saved query is a personal, per-user asset: the REST API scopes read,
update and delete to ``created_by == g.user`` (``SavedQueryFilter``).
The same object-level rule is enforced here on the overwrite path so an
importer cannot replace another user's saved query (and the SQL the
victim will later run under their own grants) by reusing its UUID in an
import bundle -- matching the permission checks every sibling importer
(chart, dashboard, dataset, database, theme) already performs.
"""
can_write = ignore_permissions or security_manager.can_access(
"can_write",
"SavedQuery",
)
existing = db.session.query(SavedQuery).filter_by(uuid=config["uuid"]).first()
if existing:
if not overwrite:
if not overwrite or not can_write:
return existing
# ``user`` is None on background paths (no Flask request user);
# combined with ``can_write`` (typically from
# ``ignore_permissions=True``) the ownership check is skipped there
# because the caller has already established trust -- mirroring the
# chart importer.
user = get_user()
if user and not (security_manager.is_admin() or existing.created_by == user):
raise ImportFailedError(
f"Saved query (uuid {config['uuid']}) already exists and "
"user doesn't have permissions to overwrite it"
)
config["id"] = existing.id
elif not can_write:
raise ImportFailedError(
"Saved query doesn't exist and user doesn't have permission to "
"create saved queries"
)
saved_query = SavedQuery.import_from_dict(config, recursive=False)
if saved_query.id is None:
+1 -1
View File
@@ -154,7 +154,7 @@ class CreateReportScheduleCommand(CreateMixin, BaseReportScheduleCommand):
if (
creation_method != ReportCreationMethod.ALERTS_REPORTS
and not ReportScheduleDAO.validate_unique_creation_method(
dashboard_id, chart_id
dashboard_id, chart_id, creation_method
)
):
raise ReportScheduleCreationMethodUniquenessValidationError()
+7 -1
View File
@@ -29,7 +29,9 @@ from superset.commands.semantic_layer.exceptions import (
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticViewCreateFailedError,
SemanticViewForbiddenError,
)
from superset.commands.utils import current_user_can_modify_object
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
from superset.semantic_layers.registry import registry
from superset.utils import json
@@ -92,9 +94,13 @@ class CreateSemanticViewCommand(BaseCommand):
def validate(self) -> None:
layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
layer = SemanticLayerDAO.find_by_uuid(layer_uuid)
if not layer:
raise SemanticLayerNotFoundError()
if not current_user_can_modify_object(layer):
raise SemanticViewForbiddenError()
name: str = self._properties.get("name", "")
configuration: dict[str, Any] = self._properties.get("configuration") or {}
if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
+9 -10
View File
@@ -21,17 +21,17 @@ from functools import partial
from sqlalchemy.exc import SQLAlchemyError
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerDeleteFailedError,
SemanticLayerForbiddenError,
SemanticLayerNotFoundError,
SemanticViewDeleteFailedError,
SemanticViewForbiddenError,
SemanticViewNotFoundError,
)
from superset.commands.utils import current_user_can_modify_object
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
from superset.exceptions import SupersetSecurityException
from superset.semantic_layers.models import SemanticLayer, SemanticView
from superset.utils.decorators import on_error, transaction
@@ -60,6 +60,9 @@ class DeleteSemanticLayerCommand(BaseCommand):
if not self._model:
raise SemanticLayerNotFoundError()
if not current_user_can_modify_object(self._model):
raise SemanticLayerForbiddenError()
class DeleteSemanticViewCommand(BaseCommand):
def __init__(self, pk: int):
@@ -82,10 +85,8 @@ class DeleteSemanticViewCommand(BaseCommand):
self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
if not self._model:
raise SemanticViewNotFoundError()
try:
security_manager.raise_for_editorship(self._model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
if not current_user_can_modify_object(self._model):
raise SemanticViewForbiddenError()
class BulkDeleteSemanticViewCommand(BaseCommand):
@@ -109,7 +110,5 @@ class BulkDeleteSemanticViewCommand(BaseCommand):
if len(self._models) != len(self._model_ids):
raise SemanticViewNotFoundError()
for model in self._models:
try:
security_manager.raise_for_editorship(model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
if not current_user_can_modify_object(model):
raise SemanticViewForbiddenError()

Some files were not shown because too many files have changed in this diff Show More