Compare commits

..
Author SHA1 Message Date
Enzo Martellucci 94d8a0bf77 Merge branch 'master' into enxdev/fix/echarts 2026-08-24 17:48:15 +02:00
Enzo Martellucci 5a1f6332bf Merge branch 'master' into enxdev/fix/echarts
# Conflicts:
#	superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
#	superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
#	superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
2026-08-24 17:00:52 +02:00
Enzo Martellucci 4d30f6fb65 fix(echarts): keep pinned weekly axis labels thinned and annotation-safe 2026-08-19 19:30:05 +02:00
Enzo Martellucci 296ab06d8d fix(echarts): place weekly time-axis ticks on the data buckets 2026-08-19 18:50:51 +02:00
56 changed files with 551 additions and 3302 deletions
@@ -40,18 +40,10 @@ 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.
@@ -87,35 +79,19 @@ 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 anyDataset(page: Page): Promise<{
id: number;
columnName: string;
}> {
async function anyDatasetId(page: Page): Promise<number> {
const named = await getDatasetByName(page, DATASET_NAME);
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;
if (named) {
return named.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,
};
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;
}
/** Open the Explore "Additional actions → View version history" panel. */
@@ -133,8 +109,7 @@ testWithAssets(
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await authorizeApi(page);
const { id: datasetId, columnName } = await anyDataset(page);
const datasetId = await anyDatasetId(page);
const baseName = `version_history_${Date.now()}`;
const chartResp = await apiPostChart(page, {
@@ -148,7 +123,7 @@ testWithAssets(
datasource: `${datasetId}__table`,
viz_type: 'table',
query_mode: 'raw',
all_columns: [columnName],
all_columns: [],
adhoc_filters: [],
row_limit: 10,
}),
@@ -196,79 +171,3 @@ 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);
},
);
@@ -73,6 +73,7 @@ import {
getLegendProps,
getMinAndMaxFromBounds,
getOverMaxHiddenFormatter,
getTemporalTickValues,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import {
@@ -758,6 +759,22 @@ export default function transformProps(
const { setDataMask = () => {}, onContextMenu } = hooks;
const alignTicks = yAxisIndex !== yAxisIndexB;
// Weekly grains: pin the ticks to the buckets. Both queries share the axis.
// Skipped when a timeseries annotation is shown: it widens the axis past the
// buckets and ECharts clips pinned ticks to the extent, leaving that span bare.
const hasTimeseriesAnnotation = annotationLayers.some(
(layer: AnnotationLayer) =>
layer.show && isTimeseriesAnnotationLayer(layer),
);
const temporalTickValues = hasTimeseriesAnnotation
? undefined
: getTemporalTickValues(
[...rebasedDataA, ...rebasedDataB],
xAxisLabel,
xAxisType,
resolvedTimeGrain,
);
const echartOptions: EChartsCoreOption = {
useUTC: true,
grid: {
@@ -770,9 +787,13 @@ export default function transformProps(
nameGap: xAxisTitleMarginPx,
nameLocation: 'middle',
axisLabel: {
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
// Pinned ticks label every bucket, so keep thinning on even when the
// showMaxLabel/rotation branch would otherwise drop it.
hideOverlap:
!!temporalTickValues ||
(showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
@@ -782,7 +803,12 @@ export default function transformProps(
showMinLabel: true,
alignMinLabel: 'left',
}),
...(temporalTickValues && { customValues: temporalTickValues }),
},
// Gridlines, when shown, follow axisTick.customValues too.
...(temporalTickValues && {
axisTick: { customValues: temporalTickValues },
}),
minorTick: { show: minorTicks },
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
@@ -102,18 +102,15 @@ 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. 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
3. the flatOperator makes multiIndex Dataframe into flat Dataframe
*/
post_processing: [
pivotOperatorInRuntime,
resampleOperator(formData, baseQueryObject),
rollingWindowOperator(formData, baseQueryObject),
timeCompareOperator(formData, baseQueryObject),
contributionOperator(formData, baseQueryObject, time_offsets),
renameOperator(formData, baseQueryObject),
contributionOperator(formData, baseQueryObject, time_offsets),
sortOperator(formData, baseQueryObject),
flattenOperator(formData, baseQueryObject),
// todo: move prophet before flatten
@@ -88,6 +88,7 @@ import {
getHorizontalLegendAvailableWidth,
getLegendProps,
getMinAndMaxFromBounds,
getTemporalTickValues,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import {
@@ -1243,6 +1244,23 @@ export default function transformProps(
})()
: xAxisFormatter;
// Weekly grains: pin the ticks to the buckets ECharts would otherwise miss.
// A timeseries annotation contributes its own timestamps and widens the axis
// past the buckets, and ECharts clips pinned ticks to the extent, so that
// span would render bare — leave those charts on ECharts' own ticks.
const hasTimeseriesAnnotation = annotationLayers.some(
(layer: AnnotationLayer) =>
layer.show && isTimeseriesAnnotationLayer(layer),
);
const temporalTickValues = hasTimeseriesAnnotation
? undefined
: getTemporalTickValues(
rebasedData,
xAxisLabel,
xAxisType,
resolvedTimeGrain,
);
let xAxis: any = {
type: xAxisType,
name: xAxisTitle,
@@ -1258,10 +1276,13 @@ export default function transformProps(
// have less overlap, so disabling hideOverlap is safe.
// 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),
// by ECharts' overlap detection (#39899). Pinned ticks label
// every bucket, which does crowd, so thinning always wins there.
hideOverlap:
!!temporalTickValues ||
(showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
@@ -1276,7 +1297,12 @@ export default function transformProps(
showMinLabel: true,
alignMinLabel: 'left',
}),
...(temporalTickValues && { customValues: temporalTickValues }),
},
// Gridlines, when shown, follow axisTick.customValues too.
...(temporalTickValues && {
axisTick: { customValues: temporalTickValues },
}),
minorTick: { show: minorTicks },
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
@@ -89,6 +89,16 @@ export const StackControlOptionsWithoutStream: [
[StackControlsValue.Stack, t('Stack')],
];
// Grains ECharts' time axis cannot tick on; see getTemporalTickValues in
// utils/series.
export const WEEKLY_TIME_GRAINS: ReadonlySet<string> = new Set([
TimeGranularity.WEEK,
TimeGranularity.WEEK_STARTING_SUNDAY,
TimeGranularity.WEEK_STARTING_MONDAY,
TimeGranularity.WEEK_ENDING_SATURDAY,
TimeGranularity.WEEK_ENDING_SUNDAY,
]);
export const TIMEGRAIN_TO_TIMESTAMP = {
[TimeGranularity.HOUR]: 3600 * 1000,
[TimeGranularity.DAY]: 3600 * 1000 * 24,
@@ -42,6 +42,7 @@ import {
NULL_STRING,
StackControlsValue,
TIMESERIES_CONSTANTS,
WEEKLY_TIME_GRAINS,
} from '../constants';
import {
EchartsTimeseriesSeriesType,
@@ -986,6 +987,45 @@ export function getAxisType(
return AxisType.Category;
}
/**
* Bucket timestamps a temporal axis should tick on, or undefined to let ECharts
* choose.
*
* ECharts generates time ticks from a calendar ladder with no week unit, so for
* weekly data it steps days from the 1st of each month instead: labels drift
* across weekdays and snap to month starts (#17226). Coarser grains already land
* on their data and keep ECharts' calendar-nice labels.
*/
export function getTemporalTickValues(
data: DataRecord[],
xAxisLabel: string,
xAxisType: AxisType,
timeGrain?: string,
): number[] | undefined {
if (
xAxisType !== AxisType.Time ||
!timeGrain ||
!WEEKLY_TIME_GRAINS.has(timeGrain)
) {
return undefined;
}
const values = new Set<number>();
data.forEach(row => {
const value = row[xAxisLabel];
const timestamp =
// eslint-disable-next-line no-nested-ternary
value instanceof Date
? value.getTime()
: typeof value === 'string'
? new Date(value).getTime()
: Number(value ?? NaN);
if (Number.isFinite(timestamp)) {
values.add(timestamp);
}
});
return values.size ? [...values].sort((a, b) => a - b) : undefined;
}
export function getOverMaxHiddenFormatter(
config: {
max?: number;
@@ -1456,3 +1456,94 @@ describe('EchartsMixedTimeseries tooltip truncation', () => {
expect(html).not.toContain(longSeriesName);
});
});
describe('weekly x-axis tick alignment', () => {
const WEEK_MS = 7 * 24 * 3600 * 1000;
const MONDAYS = Array.from(
{ length: 6 },
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
);
const weeklyLabelMap = { ds: ['ds'], sum__num: ['sum__num'] };
const weeklyQuery = (timestamps: number[]) =>
createTestQueryData(
timestamps.map((ds, i) => ({ ds, sum__num: 10 + i })),
{
label_map: weeklyLabelMap,
colnames: ['ds', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
);
const weeklyChartProps = (
queryA: number[],
queryB: number[],
overrides: Partial<EchartsMixedTimeseriesFormData> = {},
) =>
createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
formData: {
...formData,
groupby: [],
groupbyB: [],
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
...overrides,
},
queriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
});
test('pins ticks, labels and gridlines to the weekly buckets', () => {
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
.echartOptions as any;
expect(xAxis.type).toBe(AxisType.Time);
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
expect(xAxis.splitLine).toBeUndefined();
});
test('keeps label thinning on when the labels are rotated', () => {
const { xAxis } = transformProps(
weeklyChartProps(MONDAYS, MONDAYS, { xAxisLabelRotation: 45 }),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('keeps label thinning on at 0° rotation, where showMaxLabel is active', () => {
// The default weekly config: unrotated labels put showMaxLabel in play,
// which must not override the pinned-tick thinning.
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
.echartOptions as any;
expect(xAxis.axisLabel.showMaxLabel).toBe(true);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('covers buckets contributed by either query', () => {
// The two queries share one axis, so a bucket present in only one of them
// still needs a tick.
const { xAxis } = transformProps(
weeklyChartProps(MONDAYS.slice(0, 3), MONDAYS.slice(2)),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('leaves grains ECharts places correctly untouched', () => {
const { xAxis } = transformProps(
weeklyChartProps(MONDAYS, MONDAYS, {
timeGrainSqla: TimeGranularity.MONTH,
}),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisTick?.customValues).toBeUndefined();
});
});
@@ -64,28 +64,6 @@ 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,
@@ -17,6 +17,7 @@
* under the License.
*/
import {
AnnotationData,
AnnotationSourceType,
AnnotationStyle,
AnnotationType,
@@ -2565,6 +2566,240 @@ describe('EchartsTimeseries tooltip truncation', () => {
});
});
describe('weekly x-axis tick alignment', () => {
// 13 Monday-aligned weekly buckets, the shape produced by a dataset that is
// pre-aggregated to weeks.
const WEEK_MS = 7 * 24 * 3600 * 1000;
const MONDAYS = Array.from(
{ length: 13 },
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
);
const weeklyChartProps = (
formDataOverrides: Partial<EchartsTimeseriesFormData> = {},
annotationData?: AnnotationData,
) =>
createTestChartProps({
annotationData,
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
xAxisTimeFormat: '%m-%d',
...formDataOverrides,
},
queriesData: [
createTestQueryData(
MONDAYS.map((__timestamp, i) => ({ __timestamp, sales: 100 + i })),
{
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
// transformProps reads annotations off the query, not chartProps.
...(annotationData && { annotation_data: annotationData }),
},
),
],
});
test('pins ticks, labels and gridlines to the weekly buckets', () => {
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
expect(xAxis.type).toBe(AxisType.Time);
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
expect(xAxis.splitLine).toBeUndefined();
});
test('keeps label thinning on at 0° rotation, where showMaxLabel is active', () => {
// The default weekly config: unrotated labels put showMaxLabel in play,
// which must not override the pinned-tick thinning.
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
expect(xAxis.axisLabel.showMaxLabel).toBe(true);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('pins ticks when the bucket column holds ISO date strings', () => {
// A dataset can arrive with __timestamp serialized as an ISO string
// rather than a Date/epoch-ms value.
const chartProps = createTestChartProps({
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
},
queriesData: [
createTestQueryData(
MONDAYS.map((__timestamp, i) => ({
__timestamp: new Date(__timestamp).toISOString(),
sales: 100 + i,
})),
{
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
),
],
});
const { xAxis } = transformProps(chartProps).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('keeps label thinning on when the labels are rotated', () => {
// Rotation normally turns hideOverlap off, but pinned ticks put a label on
// every bucket, so without thinning a multi-year range draws hundreds.
const { xAxis } = transformProps(
weeklyChartProps({ xAxisLabelRotation: 45 }),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('leaves rotation thinning alone when the ticks are not pinned', () => {
const { xAxis } = transformProps(
weeklyChartProps({
timeGrainSqla: TimeGranularity.MONTH,
xAxisLabelRotation: 45,
}),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisLabel.hideOverlap).toBe(false);
});
const timeseriesLayer = (show: boolean) =>
({
name: 'my annotation',
annotationType: AnnotationType.Timeseries,
sourceType: AnnotationSourceType.Line,
style: AnnotationStyle.Solid,
show,
value: 1,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
// The annotation's own timestamps run a year past the last bucket.
const annotationRecords = {
'my annotation': {
records: [
{ ds: MONDAYS[0], y: 1 },
{ ds: MONDAYS[12] + 52 * WEEK_MS, y: 2 },
],
},
};
test('does not pin ticks when a timeseries annotation widens the axis', () => {
// A Time axis takes no min/max, so it stretches to cover the annotation
// while ECharts clips pinned ticks to the extent — that span would be bare.
const { xAxis } = transformProps(
weeklyChartProps(
{ annotationLayers: [timeseriesLayer(true)] },
annotationRecords,
),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisTick?.customValues).toBeUndefined();
});
test('still pins ticks for a hidden timeseries annotation', () => {
const { xAxis } = transformProps(
weeklyChartProps(
{ annotationLayers: [timeseriesLayer(false)] },
annotationRecords,
),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test.each([
TimeGranularity.WEEK,
TimeGranularity.WEEK_STARTING_SUNDAY,
TimeGranularity.WEEK_STARTING_MONDAY,
TimeGranularity.WEEK_ENDING_SATURDAY,
TimeGranularity.WEEK_ENDING_SUNDAY,
])('applies to the %s grain', grain => {
const { xAxis } = transformProps(weeklyChartProps({ timeGrainSqla: grain }))
.echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('a dashboard time-grain override drives the alignment', () => {
const { xAxis } = transformProps(
weeklyChartProps({
timeGrainSqla: TimeGranularity.DAY,
extraFormData: { time_grain_sqla: TimeGranularity.WEEK },
}),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('deduplicates and sorts the bucket timestamps', () => {
// A grouped query repeats each bucket once per series, and the rows are
// not necessarily ordered.
const chartProps = createTestChartProps({
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK,
groupby: ['region'],
},
queriesData: [
createTestQueryData(
[
{ __timestamp: MONDAYS[1], region: 'b', sales: 2 },
{ __timestamp: MONDAYS[0], region: 'a', sales: 1 },
{ __timestamp: MONDAYS[1], region: 'a', sales: 3 },
{ __timestamp: MONDAYS[0], region: 'b', sales: 4 },
],
{
colnames: ['__timestamp', 'region', 'sales'],
coltypes: [
GenericDataType.Temporal,
GenericDataType.String,
GenericDataType.Numeric,
],
},
),
],
});
const { xAxis } = transformProps(chartProps).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual([MONDAYS[0], MONDAYS[1]]);
});
test('leaves grains ECharts places correctly untouched', () => {
(
[
TimeGranularity.DAY,
TimeGranularity.MONTH,
TimeGranularity.QUARTER,
TimeGranularity.YEAR,
undefined,
] as const
).forEach(grain => {
const { xAxis } = transformProps(
weeklyChartProps({ timeGrainSqla: grain }),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisTick?.customValues).toBeUndefined();
});
});
test('leaves a categorical x-axis untouched', () => {
const { xAxis } = transformProps(
weeklyChartProps({ xAxisForceCategorical: true }),
).echartOptions as any;
expect(xAxis.type).toBe(AxisType.Category);
expect(xAxis.axisLabel.customValues).toBeUndefined();
});
});
describe('tooltip for metrics whose labels end in forecast suffixes', () => {
const marker = '<span style="background-color:#1f77b4;"></span>';
const seriesIds = ['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper'];
@@ -17,20 +17,10 @@
* under the License.
*/
import { isFeatureEnabled, VizType } from '@superset-ui/core';
import { HYDRATE_CHART_NORMALIZATION } from 'src/features/versionHistory/reducer';
import { VizType } from '@superset-ui/core';
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({}, '', '/');
});
@@ -353,67 +343,3 @@ 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,10 +49,6 @@ 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',
@@ -82,8 +78,6 @@ 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]
@@ -219,10 +213,6 @@ export const hydrateExplore =
exploreState,
);
});
const hydratedFormData = {
...initialFormData,
...getFormDataFromControls(exploreState.controls),
};
const sliceFormData = initialSlice
? getFormDataFromControls(initialControls)
: null;
@@ -243,7 +233,7 @@ export const hydrateExplore =
lastRendered: 0,
};
const result = dispatch({
return dispatch({
type: HYDRATE_EXPLORE,
data: {
charts: {
@@ -263,28 +253,6 @@ 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,7 +21,6 @@ import { Dispatch } from 'redux';
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
import {
DatasourceType,
isFeatureEnabled,
QueryFormData,
SimpleAdhocFilter,
VizType,
@@ -38,13 +37,6 @@ 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';
@@ -100,159 +92,17 @@ 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();
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],
}),
]);
});
beforeEach(() => fetchMock.clearHistory().removeRoutes());
/**
* Tests updateSlice action
*/
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
test('updateSlice handles success', async () => {
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
name: updateSliceEndpoint,
@@ -884,97 +734,3 @@ 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,8 +21,6 @@ import { Dispatch } from 'redux';
import { t } from '@apache-superset/core/translation';
import {
DatasourceType,
FeatureFlag,
isFeatureEnabled,
type QueryFormData,
SimpleAdhocFilter,
SupersetClient,
@@ -32,25 +30,11 @@ 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/;
@@ -249,84 +233,21 @@ export const updateSlice =
new?: boolean;
},
) =>
async (
dispatch: Dispatch,
getState: () => Partial<QueryFormData> & {
versionHistory?: {
chartNormalization?: ChartNormalizationTrackingState | null;
};
explore?: {
form_data?: QueryFormData;
hiddenFormData?: Record<string, unknown>;
};
},
) => {
async (dispatch: Dispatch, getState: () => Partial<QueryFormData>) => {
const { slice_id: sliceId, editors, form_data: formDataFromSlice } = slice;
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,
),
);
}
const formData = getState().explore?.form_data;
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: payload,
jsonPayload: await getSlicePayload(
sliceName,
formData,
dashboards,
editors as [],
formDataFromSlice,
),
});
if (shouldAttachNormalization) {
dispatch(
completeChartNormalizationSave(
sliceId,
tracking.hydrationSessionId,
saveAttemptId,
{},
),
);
}
dispatch(saveSliceSuccess(response.json));
addToasts(false, sliceName, addedToDashboard).map(dispatch);
return response.json;
@@ -1,158 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
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({});
});
@@ -1,197 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 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,14 +18,10 @@
*/
import versionHistoryReducer, {
appendVersionSessionLog,
beginChartNormalizationSave,
clearVersionPreview,
clearVersionSessionLog,
completeChartNormalizationSave,
closeVersionHistoryPanel,
openVersionHistoryPanel,
hydrateChartNormalization,
invalidateChartNormalizationControls,
selectIsChartVersionPreviewActive,
selectIsDashboardVersionPreviewActive,
selectVersionHistory,
@@ -149,55 +145,3 @@ 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,8 +18,6 @@
*/
import type {
ActivityInclude,
AutomaticNormalizationTransitions,
ChartNormalizationTrackingState,
SessionLogEntry,
VersionedEntityType,
VersionHistoryState,
@@ -35,12 +33,6 @@ 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;
@@ -94,31 +86,6 @@ 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
@@ -128,11 +95,7 @@ export type VersionHistoryAction =
| PreviewAppliedAction
| VersionRestoredAction
| AppendSessionLogAction
| ClearSessionLogAction
| HydrateChartNormalizationAction
| InvalidateChartNormalizationControlsAction
| BeginChartNormalizationSaveAction
| CompleteChartNormalizationSaveAction;
| ClearSessionLogAction;
export const openVersionHistoryPanel = (
entityType: VersionedEntityType,
@@ -198,44 +161,6 @@ 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,
@@ -245,7 +170,6 @@ const initialState: VersionHistoryState = {
sessionLog: [],
restoreCount: 0,
lastRestoredEntityUuid: null,
chartNormalization: null,
};
export default function versionHistoryReducer(
@@ -316,58 +240,6 @@ 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;
}
@@ -420,6 +292,3 @@ export const selectVersionLastRestoredUuid = (state: VersionHistoryRootState) =>
export const selectVersionSessionLog = (state: VersionHistoryRootState) =>
selectVersionHistory(state).sessionLog;
export const selectChartNormalization = (state: VersionHistoryRootState) =>
selectVersionHistory(state).chartNormalization;
@@ -21,7 +21,6 @@ import { versionSessionLogMiddleware } from './sessionLogMiddleware';
import {
APPEND_VERSION_SESSION_LOG,
CLEAR_VERSION_SESSION_LOG,
INVALIDATE_CHART_NORMALIZATION_CONTROLS,
} from './reducer';
jest.mock('@superset-ui/core', () => ({
@@ -78,7 +77,7 @@ test('falls back to a humanized control name when no label exists', () => {
);
});
test('programmatic writes invalidate normalization without logging an edit', async () => {
test('skips programmatic control writes so untouched charts stay clean', 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
@@ -90,54 +89,15 @@ test('programmatic writes invalidate normalization without logging an edit', asy
explore: { controls: { metrics: { label: 'Metrics' } } },
});
run(store, setControlValue('metrics', [], undefined, { programmatic: true }));
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith({
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
controls: ['metrics'],
});
expect(store.dispatch).not.toHaveBeenCalled();
// 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: {} });
@@ -228,7 +188,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;
const { entry } = store.dispatch.mock.calls[0][0];
expect(entry.controlName).not.toMatch(/^[a-z]/);
});
@@ -19,13 +19,7 @@
import type { Middleware } from 'redux';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import {
appendVersionSessionLog,
clearVersionSessionLog,
invalidateChartNormalizationControls,
} from './reducer';
import { jsonValuesEqual } from './normalization';
import type { ChartNormalizationTrackingState } from './types';
import { appendVersionSessionLog, clearVersionSessionLog } from './reducer';
// Action types are inlined (rather than imported from the explore
// module) so this middleware does not pull explore code into every
@@ -53,54 +47,9 @@ 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
@@ -115,25 +64,6 @@ 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
@@ -141,7 +71,6 @@ const normalizationControlsNoLongerMatching = (
*/
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;
@@ -222,17 +151,5 @@ 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,45 +209,6 @@ 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;
@@ -267,6 +228,4 @@ export interface VersionHistoryState {
* the one their page shows.
*/
lastRestoredEntityUuid: string | null;
/** Advisory transitions for the active Explore chart hydration. */
chartNormalization?: ChartNormalizationTrackingState | null;
}
+2 -6
View File
@@ -427,7 +427,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
@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:
@@ -691,17 +691,13 @@ 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, normalization_changes=normalization_changes
).run()
changed_model = UpdateChartCommand(pk, item).run()
new_info = current_entity_version_info(
Slice, changed_model.id, changed_model.uuid
)
-24
View File
@@ -368,30 +368,6 @@ 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):
+3 -22
View File
@@ -44,15 +44,11 @@ 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__)
@@ -64,16 +60,10 @@ def is_query_context_update(properties: dict[str, Any]) -> bool:
class UpdateChartCommand(UpdateMixin, BaseCommand):
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()
def __init__(self, model_id: int, data: dict[str, Any]):
self._model_id = model_id
self._properties = 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:
@@ -88,15 +78,6 @@ 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(
+1 -7
View File
@@ -29,9 +29,7 @@ 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
@@ -94,13 +92,9 @@ class CreateSemanticViewCommand(BaseCommand):
def validate(self) -> None:
layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
layer = SemanticLayerDAO.find_by_uuid(layer_uuid)
if not layer:
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
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):
+10 -9
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,9 +60,6 @@ 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):
@@ -85,8 +82,10 @@ class DeleteSemanticViewCommand(BaseCommand):
self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
if not self._model:
raise SemanticViewNotFoundError()
if not current_user_can_modify_object(self._model):
raise SemanticViewForbiddenError()
try:
security_manager.raise_for_editorship(self._model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
class BulkDeleteSemanticViewCommand(BaseCommand):
@@ -110,5 +109,7 @@ class BulkDeleteSemanticViewCommand(BaseCommand):
if len(self._models) != len(self._model_ids):
raise SemanticViewNotFoundError()
for model in self._models:
if not current_user_can_modify_object(model):
raise SemanticViewForbiddenError()
try:
security_manager.raise_for_editorship(model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
+6 -7
View File
@@ -23,9 +23,9 @@ from typing import Any
from flask_appbuilder.models.sqla import Model
from sqlalchemy.exc import SQLAlchemyError
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticLayerUpdateFailedError,
@@ -33,8 +33,8 @@ from superset.commands.semantic_layer.exceptions import (
SemanticViewNotFoundError,
SemanticViewUpdateFailedError,
)
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.semantic_layers.registry import registry
from superset.utils import json
@@ -66,8 +66,10 @@ class UpdateSemanticViewCommand(BaseCommand):
if not self._model:
raise SemanticViewNotFoundError()
if not current_user_can_modify_object(self._model):
raise SemanticViewForbiddenError()
try:
security_manager.raise_for_editorship(self._model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
name = self._properties.get("name", self._model.name)
layer_uuid = str(self._model.semantic_layer_uuid)
@@ -114,9 +116,6 @@ class UpdateSemanticLayerCommand(BaseCommand):
if not self._model:
raise SemanticLayerNotFoundError()
if not current_user_can_modify_object(self._model):
raise SemanticLayerForbiddenError()
name = self._properties.get("name")
if name and not SemanticLayerDAO.validate_update_uniqueness(self._uuid, name):
raise SemanticLayerInvalidError(f"Name already exists: {name}")
+5 -2
View File
@@ -21,8 +21,11 @@ from typing import Any
from superset import security_manager
from superset.commands.base import BaseCommand, CreateMixin
from superset.commands.tag.exceptions import TagCreateFailedError, TagInvalidError
from superset.commands.tag.utils import to_object_model, to_object_type
from superset.commands.utils import current_user_can_modify_object
from superset.commands.tag.utils import (
current_user_can_modify_object,
to_object_model,
to_object_type,
)
from superset.daos.tag import TagDAO
from superset.exceptions import SupersetSecurityException
from superset.tags.models import ObjectType, TagType
+5 -2
View File
@@ -22,8 +22,11 @@ from flask_appbuilder.models.sqla import Model
from superset import db
from superset.commands.base import BaseCommand, UpdateMixin
from superset.commands.tag.exceptions import TagInvalidError, TagNotFoundError
from superset.commands.tag.utils import to_object_model, to_object_type
from superset.commands.utils import current_user_can_modify_object
from superset.commands.tag.utils import (
current_user_can_modify_object,
to_object_model,
to_object_type,
)
from superset.daos.tag import TagDAO
from superset.tags.models import Tag
from superset.utils.decorators import transaction
+21
View File
@@ -17,9 +17,11 @@
from typing import Any, Optional, Union
from superset import security_manager
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.daos.query import SavedQueryDAO
from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.models.sql_lab import SavedQuery
@@ -50,3 +52,22 @@ def to_object_model(
return DatasetDAO.find_by_id(object_id, skip_base_filter=skip_base_filter)
return None
def current_user_can_modify_object(model: Any) -> bool:
"""Whether the current user may create/modify tag relationships on ``model``.
Mirrors the editorship check the bulk-create path already applies, or the
object's creator, so the tag-update path enforces the same boundary.
Look the model up with
``skip_base_filter=True`` before calling this, so an object the user cannot
access reaches the check instead of resolving to ``None`` and being written
without any check.
"""
try:
security_manager.raise_for_editorship(model)
return True
except SupersetSecurityException:
return bool(
model.created_by and model.created_by == security_manager.current_user
)
-12
View File
@@ -44,18 +44,6 @@ def import_theme(config: dict[str, Any], overwrite: bool = False) -> "Theme | No
if existing:
if not overwrite or not can_write:
return existing
if existing.is_system:
raise ThemeImportError("Cannot overwrite a system theme via import")
# The active system-default/dark theme slot may be overwritten by
# admins only; a non-admin overwriting it would change the theme
# rendered for every user, including the login page and other
# admins.
if (
existing.is_system_default or existing.is_system_dark
) and not security_manager.is_admin():
raise ThemeImportError(
"Cannot overwrite the active system-default/dark theme via import"
)
config["id"] = existing.id
elif not can_write:
raise ThemeImportError(
-10
View File
@@ -18,10 +18,8 @@ import logging
from functools import partial
from typing import Any, Optional
from superset import security_manager
from superset.commands.base import UpdateMixin
from superset.commands.theme.exceptions import (
SystemThemeInUseError,
SystemThemeProtectedError,
ThemeNotFoundError,
)
@@ -54,11 +52,3 @@ class UpdateThemeCommand(UpdateMixin):
# Check if it's a system theme
if self._model.is_system:
raise SystemThemeProtectedError()
# The active system-default/dark theme slot may be edited by admins
# only; a non-admin editing it would change the theme rendered for
# every user, including the login page and other admins.
if (
self._model.is_system_default or self._model.is_system_dark
) and not security_manager.is_admin():
raise SystemThemeInUseError()
-25
View File
@@ -32,7 +32,6 @@ from superset.commands.exceptions import (
from superset.daos.datasource import DatasourceDAO
from superset.daos.exceptions import DatasourceNotFound
from superset.daos.tag import TagDAO
from superset.exceptions import SupersetSecurityException
from superset.subjects.exceptions import SubjectsNotFoundValidationError
from superset.subjects.models import Subject
from superset.subjects.utils import (
@@ -53,30 +52,6 @@ def _has_extra_editors_resolver() -> bool:
return bool(has_app_context() and current_app.config.get("EXTRA_EDITORS_RESOLVER"))
def current_user_can_modify_object(model: Any) -> bool:
"""Whether the current user is authorized to create/modify ``model``.
Delegates to ``security_manager.raise_for_editorship``, which grants
access to admins and any subject in ``model.editors`` (when that
relationship exists). For models that don't carry an ``editors``
relationship, or when the current subject isn't one of them, this falls
back to allowing the object's creator.
Callers that need to distinguish "not found" from "no access" should
look the model up bypassing DAO base filters (e.g.
``skip_base_filter=True``) before calling this, so an object the user
cannot access reaches the check instead of resolving to ``None`` and
being written without any check.
"""
try:
security_manager.raise_for_editorship(model)
return True
except SupersetSecurityException:
return bool(
model.created_by and model.created_by == security_manager.current_user
)
def populate_subject_list(
subject_ids: list[int] | None,
default_to_user: bool,
-9
View File
@@ -30,7 +30,6 @@ from superset.commands.temporary_cache.exceptions import (
TemporaryCacheResourceNotFoundError,
)
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from superset.exceptions import SupersetTemplateException
from superset.explore.form_data.schemas import FormDataPostSchema, FormDataPutSchema
from superset.extensions import event_logger
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
@@ -111,8 +110,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
@expose("/form_data/<string:key>", methods=("PUT",))
@protect()
@@ -186,8 +183,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
@expose("/form_data/<string:key>", methods=("GET",))
@protect()
@@ -239,8 +234,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
@expose("/form_data/<string:key>", methods=("DELETE",))
@protect()
@@ -293,5 +286,3 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
+1 -10
View File
@@ -16,8 +16,6 @@
# under the License.
from typing import Optional
from jinja2.exceptions import TemplateError
from superset import security_manager
from superset.commands.chart.exceptions import (
ChartAccessDeniedError,
@@ -35,7 +33,6 @@ from superset.commands.exceptions import (
from superset.daos.chart import ChartDAO
from superset.daos.dataset import DatasetDAO
from superset.daos.query import QueryDAO
from superset.exceptions import SupersetTemplateException
from superset.utils.core import DatasourceType
@@ -56,13 +53,7 @@ def check_query_access(query_id: int) -> Optional[bool]:
# Access checks below, no need to validate them twice as they can be expensive.
query = QueryDAO.find_by_id(query_id, skip_base_filter=True)
if query:
try:
security_manager.raise_for_access(query=query)
except TemplateError as ex:
# raise_for_access() Jinja-renders the query's SQL to resolve
# the tables it touches; a malformed template surfaces here as
# a raw jinja2 exception rather than a Superset one.
raise SupersetTemplateException(str(ex)) from ex
security_manager.raise_for_access(query=query)
return True
raise QueryNotFoundValidationError()
-1
View File
@@ -1728,7 +1728,6 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"CssTemplate",
"Dataset",
"Datasource",
"Theme",
} | READ_ONLY_MODEL_VIEWS
GAMMA_EXCLUDED_PVMS = {
-37
View File
@@ -41,7 +41,6 @@ from superset.commands.semantic_layer.delete import (
from superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerDeleteFailedError,
SemanticLayerForbiddenError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticLayerUpdateFailedError,
@@ -211,8 +210,6 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
description: Semantic view structure
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
@@ -341,8 +338,6 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
errors.append(
{"name": view_data.get("name"), "error": "Semantic layer not found"}
)
except SemanticViewForbiddenError as ex:
errors.append({"name": view_data.get("name"), "error": str(ex)})
except SemanticViewCreateFailedError as ex:
logger.error(
"Error creating semantic view: %s",
@@ -452,8 +447,6 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
description: Semantic view deleted
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
@@ -672,8 +665,6 @@ class SemanticLayerRestApi(BaseSupersetApi):
description: Runtime JSON Schema
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
@@ -681,11 +672,6 @@ class SemanticLayerRestApi(BaseSupersetApi):
if not layer:
return self.response_404()
try:
layer.raise_for_access()
except SupersetSecurityException as ex:
return self.response(403, message=ex.message)
body = request.get_json(silent=True) or {}
runtime_data = body.get("runtime_data")
@@ -730,8 +716,6 @@ class SemanticLayerRestApi(BaseSupersetApi):
description: Available views
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
@@ -742,11 +726,6 @@ class SemanticLayerRestApi(BaseSupersetApi):
if not layer:
return self.response_404()
try:
layer.raise_for_access()
except SupersetSecurityException as ex:
return self.response(403, message=ex.message)
body = request.get_json(silent=True) or {}
runtime_data = body.get("runtime_data", {})
@@ -876,8 +855,6 @@ class SemanticLayerRestApi(BaseSupersetApi):
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
@@ -893,8 +870,6 @@ class SemanticLayerRestApi(BaseSupersetApi):
return self.response(200, result={"uuid": str(changed_model.uuid)})
except SemanticLayerNotFoundError:
return self.response_404()
except SemanticLayerForbiddenError as ex:
return self.response(403, message=str(ex))
except SemanticLayerInvalidError as ex:
return self.response_422(message=str(ex))
except SemanticLayerUpdateFailedError as ex:
@@ -924,8 +899,6 @@ class SemanticLayerRestApi(BaseSupersetApi):
description: Semantic layer deleted
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
@@ -936,8 +909,6 @@ class SemanticLayerRestApi(BaseSupersetApi):
return self.response(200, message="OK")
except SemanticLayerNotFoundError:
return self.response_404()
except SemanticLayerForbiddenError as ex:
return self.response(403, message=str(ex))
except SemanticLayerDeleteFailedError as ex:
logger.error(
"Error deleting semantic layer: %s",
@@ -1181,18 +1152,10 @@ class SemanticLayerRestApi(BaseSupersetApi):
description: A semantic layer
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
layer = SemanticLayerDAO.find_by_uuid(uuid)
if not layer:
return self.response_404()
try:
layer.raise_for_access()
except SupersetSecurityException as ex:
return self.response(403, message=ex.message)
return self.response(200, result=_serialize_layer(layer))
-20
View File
@@ -148,26 +148,6 @@ class SemanticLayer(AuditMixinNullable, Model):
"""Compute the permission string for this semantic layer."""
return f"[{self.name}](id:{self.uuid.hex})"
def raise_for_access(self) -> None:
"""Check that the user has access to this semantic layer."""
from superset import security_manager
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
if security_manager.can_access_all_datasources():
return
if self.perm and security_manager.can_access("datasource_access", self.perm):
return
raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message=str(_("You don't have access to this semantic layer.")),
level=ErrorLevel.ERROR,
)
)
@staticmethod
def after_insert(
mapper: Mapper,
-2
View File
@@ -350,8 +350,6 @@ class ThemeRestApi(BaseSupersetModelRestApi):
return self.response_404()
except SystemThemeProtectedError:
return self.response_403()
except SystemThemeInUseError:
return self.response_403()
except Exception as ex:
logger.exception("Unexpected error in PUT /theme/%s", pk)
return self.response_422(message=str(ex))
+1 -13
View File
@@ -626,21 +626,9 @@ def sanitize_svg_content(svg_content: str) -> str:
return ""
# Minimal protection: remove obvious malicious content, preserve all SVG features
# The closing tag pattern tolerates attributes/whitespace after "script"
# (e.g. "</script foo>"), which browsers still parse as a valid closer.
content = re.sub(
r"<script\b[^>]*>.*?</script\b[^>]*>",
"",
svg_content,
flags=re.IGNORECASE | re.DOTALL,
r"<script[^>]*>.*?</script>", "", svg_content, flags=re.IGNORECASE | re.DOTALL
)
# Second pass: an unterminated <script ...> opener has no matching
# closer, so browsers treat everything after it as script content
# through end-of-file. Drop the opener and the remainder of the
# content with it, rather than leaving the payload text behind.
content = re.sub(r"<script\b[^>]*>.*", "", content, flags=re.IGNORECASE | re.DOTALL)
# Drop any orphaned closing </script ...> fragment too.
content = re.sub(r"</script\b[^>]*>?", "", content, flags=re.IGNORECASE)
content = re.sub(r"javascript:", "", content, flags=re.IGNORECASE)
content = re.sub(r"data:[^;]*;[^,]*,.*javascript", "", content, flags=re.IGNORECASE)
-2
View File
@@ -50,7 +50,6 @@ from sqlalchemy import event
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session, SessionTransaction
from superset.versioning.changes.normalization import NORMALIZATION_CONTEXT_KEY
from superset.versioning.changes.shadow_queries import (
_dashboard_child_records_for_tx_from_shadows,
_dataset_child_records_for_tx_from_shadows,
@@ -218,7 +217,6 @@ def _reset_transaction_state(session: Session) -> None:
session.info.pop(ACTION_META_KEY, None)
session.info.pop(_INITIAL_STATES_KEY, None)
session.info.pop(_FINALIZING_KEY, None)
session.info.pop(NORMALIZATION_CONTEXT_KEY, None)
def _reset_after_outer_transaction(
@@ -1,300 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Types and bounds for chart normalization change summaries."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Final, NotRequired, TypeAlias, TypedDict, TypeGuard
from uuid import uuid4
from sqlalchemy.orm import Session
from superset.utils import json
from superset.versioning.diff import ChangeRecord
JsonScalar: TypeAlias = None | bool | int | float | str
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
MAX_NORMALIZATION_TRANSITIONS: Final[int] = 256
MAX_CONTROL_NAME_BYTES: Final[int] = 256
MAX_NORMALIZATION_METADATA_BYTES: Final[int] = 256 * 1024
MAX_NORMALIZATION_VALUE_DEPTH: Final[int] = 20
NORMALIZATION_CONTEXT_KEY: Final[str] = "_versioning_chart_normalization_context"
logger: logging.Logger = logging.getLogger(__name__)
class NormalizationTransitionPayload(TypedDict):
"""Presence-aware transition received as advisory request metadata."""
control: str
from_present: bool
from_value: NotRequired[JsonValue]
to_present: bool
to_value: NotRequired[JsonValue]
@dataclass(frozen=True)
class NormalizationTransition:
"""Validated top-level chart params transition."""
control: str
from_present: bool
from_value: JsonValue
to_present: bool
to_value: JsonValue
@dataclass(frozen=True)
class NormalizationContext:
"""Consume-once evidence scoped to one chart update operation."""
chart_id: int
operation_token: str
transitions: tuple[NormalizationTransition, ...]
@dataclass
class NormalizationContextRegistry:
"""Operation-token registry retained for the active transaction."""
contexts: dict[tuple[int, str], NormalizationContext]
active_tokens: dict[int, str | None]
class _InvalidNormalizationEnvelopeError(ValueError):
"""Advisory metadata whose ambiguity requires rejecting all transitions."""
def _json_depth(value: JsonValue) -> int:
if isinstance(value, list):
return 1 + max((_json_depth(item) for item in value), default=0)
if isinstance(value, dict):
return 1 + max((_json_depth(item) for item in value.values()), default=0)
return 0
def _is_json_value(value: object) -> TypeGuard[JsonValue]:
if value is None or isinstance(value, (bool, int, float, str)):
return True
if isinstance(value, list):
return all(_is_json_value(item) for item in value)
if isinstance(value, dict):
return all(
isinstance(key, str) and _is_json_value(item) for key, item in value.items()
)
return False
def _json_equal(left: JsonValue, right: JsonValue) -> bool:
"""Compare JSON values without Python's ``True == 1`` coercion."""
if type(left) is not type(right):
return False
if isinstance(left, list) and isinstance(right, list):
return len(left) == len(right) and all(
_json_equal(a, b) for a, b in zip(left, right, strict=False)
)
if isinstance(left, dict) and isinstance(right, dict):
return left.keys() == right.keys() and all(
_json_equal(left[key], right[key]) for key in left
)
return left == right
def _parse_normalization_transition(
item: object,
) -> NormalizationTransition | None:
"""Parse one transition, skipping malformed entries without ambiguity."""
if not isinstance(item, dict):
return None
control: object = item.get("control")
from_present: object = item.get("from_present")
to_present: object = item.get("to_present")
if (
not isinstance(control, str)
or not control
or len(control.encode()) > MAX_CONTROL_NAME_BYTES
or not isinstance(from_present, bool)
or not isinstance(to_present, bool)
):
return None
if (from_present != ("from_value" in item)) or (to_present != ("to_value" in item)):
return None
from_value: object = item.get("from_value")
to_value: object = item.get("to_value")
if not _is_json_value(from_value) or not _is_json_value(to_value):
return None
if (
_json_depth(from_value) > MAX_NORMALIZATION_VALUE_DEPTH
or _json_depth(to_value) > MAX_NORMALIZATION_VALUE_DEPTH
):
raise _InvalidNormalizationEnvelopeError
return NormalizationTransition(
control=control,
from_present=from_present,
from_value=from_value,
to_present=to_present,
to_value=to_value,
)
def sanitize_normalization_changes(
raw: object,
) -> tuple[NormalizationTransition, ...]:
"""Return bounded valid entries, or no exclusions for an invalid envelope."""
try:
encoded: bytes = json.dumps(
raw, ensure_ascii=False, separators=(",", ":")
).encode()
if (
not isinstance(raw, list)
or len(raw) > MAX_NORMALIZATION_TRANSITIONS
or len(encoded) > MAX_NORMALIZATION_METADATA_BYTES
):
return ()
transitions: list[NormalizationTransition] = []
controls: set[str] = set()
for item in raw:
transition: NormalizationTransition | None = (
_parse_normalization_transition(item)
)
if transition is None:
continue
if transition.control in controls:
return ()
controls.add(transition.control)
transitions.append(transition)
return tuple(transitions)
except (
_InvalidNormalizationEnvelopeError,
TypeError,
ValueError,
UnicodeError,
RecursionError,
):
return ()
def matching_normalization_context(
chart_id: int,
raw: object,
before_params: dict[str, JsonValue],
after_params: dict[str, JsonValue],
) -> NormalizationContext | None:
"""Match sanitized advisory transitions against exact params states."""
matching: list[NormalizationTransition] = []
for transition in sanitize_normalization_changes(raw):
before_present: bool = transition.control in before_params
after_present: bool = transition.control in after_params
if (
before_present != transition.from_present
or after_present != transition.to_present
):
continue
if before_present and not _json_equal(
before_params[transition.control], transition.from_value
):
continue
if after_present and not _json_equal(
after_params[transition.control], transition.to_value
):
continue
matching.append(transition)
if not matching:
return None
return NormalizationContext(chart_id, str(uuid4()), tuple(matching))
def register_matching_normalization_context(
session: Session,
chart_id: int,
raw: object,
before_params_json: str | bytes | bytearray | None,
after_params_json: str | bytes | bytearray | None,
) -> None:
"""Validate and register advisory evidence for one chart update."""
if raw is None:
return
try:
before_params: object = json.loads(before_params_json or "{}")
after_params: object = json.loads(after_params_json or "{}")
if not isinstance(before_params, dict) or not isinstance(after_params, dict):
return
context: NormalizationContext | None = matching_normalization_context(
chart_id, raw, before_params, after_params
)
if context is not None:
store_normalization_context(session, context)
except Exception: # pylint: disable=broad-except
logger.exception(
"Ignoring chart normalization metadata for chart id=%s", chart_id
)
def store_normalization_context(
session: Session, context: NormalizationContext
) -> None:
"""Store one operation's evidence, invalidating ambiguous same-chart evidence."""
registry: NormalizationContextRegistry = session.info.setdefault(
NORMALIZATION_CONTEXT_KEY,
NormalizationContextRegistry(contexts={}, active_tokens={}),
)
existing_token: str | None = registry.active_tokens.get(context.chart_id)
if existing_token is not None:
registry.contexts.pop((context.chart_id, existing_token), None)
registry.active_tokens[context.chart_id] = None
return
if context.chart_id in registry.active_tokens:
return
registry.contexts[(context.chart_id, context.operation_token)] = context
registry.active_tokens[context.chart_id] = context.operation_token
def consume_normalization_context(
session: Session, chart_id: int
) -> NormalizationContext | None:
"""Consume chart-scoped evidence at most once."""
registry: NormalizationContextRegistry | None = session.info.get(
NORMALIZATION_CONTEXT_KEY
)
if registry is None:
return None
operation_token: str | None = registry.active_tokens.pop(chart_id, None)
if operation_token is None:
return None
return registry.contexts.pop((chart_id, operation_token), None)
def filter_normalization_records(
records: list[ChangeRecord], context: NormalizationContext | None
) -> list[ChangeRecord]:
"""Return a fresh readable diff with exact normalization controls omitted."""
if context is None:
return list(records)
controls: set[str] = {transition.control for transition in context.transitions}
return [
record
for record in records
if not (
len(record.path) >= 2
and record.path[0] == "params"
and record.path[1] in controls
)
]
-14
View File
@@ -46,10 +46,6 @@ import sqlalchemy as sa
from flask_appbuilder import Model
from sqlalchemy.orm import Session
from superset.versioning.changes.normalization import (
consume_normalization_context,
filter_normalization_records,
)
from superset.versioning.changes.table import version_changes_table
from superset.versioning.diff import (
cap_records,
@@ -217,16 +213,6 @@ def bulk_insert_records(
return
rows = []
for (entity_kind, entity_id), records in buffered.items():
if entity_kind == "chart":
try:
records = filter_normalization_records(
records, consume_normalization_context(session, entity_id)
)
except Exception: # pylint: disable=broad-except
logger.exception(
"version_changes: normalization filtering failed for chart id=%s",
entity_id,
)
# Bound a single save's output: collapse field-level record explosions
# and truncate over-large values before they hit version_changes.
for seq, r in enumerate(cap_records(records)):
@@ -23,11 +23,7 @@ from superset.models.core import Theme
from superset.utils import json
from tests.conftest import with_config
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import (
ADMIN_USERNAME,
ALPHA_USERNAME,
GAMMA_USERNAME,
)
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
class TestThemeAPIPermissions(SupersetTestCase):
@@ -96,11 +92,9 @@ class TestThemeAPIPermissions(SupersetTestCase):
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": True})
def test_non_admin_cannot_set_system_default(self):
"""Test that a non-admin user with theme write access (Alpha) still
cannot set system themes, since that is an admin-only action."""
# Login as alpha user, who has generic write access to themes but
# is not an admin
self.login(ALPHA_USERNAME)
"""Test that non-admin users cannot set system themes"""
# Login as gamma user
self.login(GAMMA_USERNAME)
# Try to set theme as system default
response = self.client.put(
@@ -116,26 +110,6 @@ class TestThemeAPIPermissions(SupersetTestCase):
theme = db.session.query(Theme).filter_by(id=self.regular_theme.id).first()
assert theme.is_system_default is False
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": True})
def test_gamma_cannot_write_themes(self):
"""Test that gamma users, who only have read access to themes, are
rejected before reaching the admin-only check."""
# Login as gamma user
self.login(GAMMA_USERNAME)
# Try to set theme as system default
response = self.client.put(
f"/api/v1/theme/{self.regular_theme.id}/set_system_default"
)
# Should be forbidden at the permission layer, since gamma has no
# write access to themes at all
assert response.status_code == 403
# Verify theme is not system default
theme = db.session.query(Theme).filter_by(id=self.regular_theme.id).first()
assert theme.is_system_default is False
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": False})
def test_system_theme_requires_config_enabled(self):
"""Test that system theme APIs require configuration to be enabled"""
@@ -52,7 +52,6 @@ from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils import json as _json
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import ADMIN_USERNAME
from tests.integration_tests.fixtures.birth_names_dashboard import ( # noqa: F401
load_birth_names_dashboard_with_slices,
load_birth_names_data,
@@ -164,142 +163,6 @@ class TestChartChangeRecords(SupersetTestCase):
assert path == ["slice_name"]
assert rows[0]["sequence"] == 0
def test_matching_hydration_metadata_omits_only_normalization_noise(
self,
) -> None:
"""Readable history omits exact null/default and missing/default changes."""
_persist_fixture_state()
chart: Slice | None = db.session.query(Slice).first()
assert chart is not None
before_params: dict[str, Any] = {
"viz_type": "table",
"granularity_sqla": "ds",
"row_limit": None,
}
after_params: dict[str, Any] = {
"viz_type": "table",
"granularity_sqla": None,
"row_limit": 10000,
"show_legend": True,
}
chart.params = _json.dumps(before_params)
db.session.commit()
metadata: list[dict[str, Any]] = [
{
"control": "granularity_sqla",
"from_present": True,
"from_value": "ds",
"to_present": True,
"to_value": None,
},
{
"control": "row_limit",
"from_present": True,
"from_value": None,
"to_present": True,
"to_value": 10000,
},
{
"control": "show_legend",
"from_present": False,
"to_present": True,
"to_value": True,
},
]
updated_name: str = f"{chart.slice_name[:64]}_intentional"
self.login(ADMIN_USERNAME)
response: Any = self.client.put(
f"/api/v1/chart/{chart.id}",
json={
"params": _json.dumps(after_params),
"slice_name": updated_name,
"normalization_changes": metadata,
},
)
assert response.status_code == 200, response.data
response_body: dict[str, Any] = _json.loads(response.data)
assert "normalization_changes" not in response_body["result"]
db.session.refresh(chart)
ver_cls: Any = version_class(Slice)
update_tx_id: int = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == chart.id)
.filter(ver_cls.operation_type == 1)
.order_by(ver_cls.transaction_id.desc())
.first()
.transaction_id
)
rows: list[dict[str, Any]] = _change_rows_for(
update_tx_id, entity_kind="chart", entity_id=chart.id
)
paths: list[list[str]] = [
_json.loads(row["path"]) if isinstance(row["path"], str) else row["path"]
for row in rows
]
assert paths == [["slice_name"]]
assert _json.loads(chart.params) == after_params
def test_null_normalization_metadata_is_ignored_by_chart_put(self) -> None:
"""Explicit null advisory metadata cannot reject an otherwise valid save."""
_persist_fixture_state()
chart: Slice | None = db.session.query(Slice).first()
assert chart is not None
self.login(ADMIN_USERNAME)
response: Any = self.client.put(
f"/api/v1/chart/{chart.id}",
json={
"slice_name": f"{chart.slice_name[:64]}_null_metadata",
"normalization_changes": None,
},
)
assert response.status_code == 200, response.data
def test_stale_normalization_metadata_fails_open_through_chart_put(self) -> None:
"""Mismatched advisory evidence preserves the real params change."""
_persist_fixture_state()
chart: Slice | None = db.session.query(Slice).first()
assert chart is not None
before_params: dict[str, Any] = {"viz_type": "table", "row_limit": 100}
after_params: dict[str, Any] = {"viz_type": "table", "row_limit": 200}
chart.params = _json.dumps(before_params)
db.session.commit()
self.login(ADMIN_USERNAME)
response: Any = self.client.put(
f"/api/v1/chart/{chart.id}",
json={
"params": _json.dumps(after_params),
"normalization_changes": [
{
"control": "row_limit",
"from_present": True,
"from_value": 999,
"to_present": True,
"to_value": 200,
}
],
},
)
assert response.status_code == 200, response.data
ver_cls: Any = version_class(Slice)
transaction_id: int = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == chart.id)
.filter(ver_cls.operation_type == 1)
.order_by(ver_cls.transaction_id.desc())
.first()
.transaction_id
)
rows: list[dict[str, Any]] = _change_rows_for(
transaction_id, entity_kind="chart", entity_id=chart.id
)
paths: list[list[str]] = [
_json.loads(row["path"]) if isinstance(row["path"], str) else row["path"]
for row in rows
]
assert ["params", "row_limit"] in paths
def test_last_saved_at_is_excluded_as_audit_noise(self) -> None:
"""``last_saved_at`` / ``last_saved_by_fk`` are save-side-effect
fields stamped by ``UpdateChartCommand`` and must not produce
@@ -25,7 +25,6 @@ from superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerInvalidError,
)
from superset.exceptions import SupersetSecurityException
def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
@@ -57,36 +56,6 @@ def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
mock_cls.from_configuration.assert_called_once_with({"account": "test"})
def test_create_semantic_layer_configuration_already_serialized(
mocker: MockerFixture,
) -> None:
"""When ``configuration`` is already a JSON string, it is passed through
to the DAO unchanged instead of being re-serialized."""
new_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao.validate_uniqueness.return_value = True
dao.create.return_value = new_model
mock_cls = MagicMock()
mocker.patch.dict(
"superset.commands.semantic_layer.create.registry",
{"snowflake": mock_cls},
)
data = {
"name": "My Layer",
"type": "snowflake",
"configuration": '{"account": "test"}',
}
result = CreateSemanticLayerCommand(data).run()
assert result == new_model
dao.create.assert_called_once_with(attributes=data)
def test_create_semantic_layer_unknown_type(mocker: MockerFixture) -> None:
"""Test that SemanticLayerInvalidError is raised for unknown type."""
mocker.patch(
@@ -197,11 +166,6 @@ def test_create_semantic_view_success(mocker: MockerFixture) -> None:
mock_model.name = "orders"
dao_view.create.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.create.current_user_can_modify_object",
return_value=True,
)
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
result = CreateSemanticViewCommand(
@@ -218,42 +182,6 @@ def test_create_semantic_view_success(mocker: MockerFixture) -> None:
)
def test_create_semantic_view_configuration_already_serialized(
mocker: MockerFixture,
) -> None:
"""When ``configuration`` is already a JSON string, it is passed through
to the DAO unchanged instead of being re-serialized."""
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
dao_view.validate_uniqueness.return_value = True
mock_model = MagicMock()
dao_view.create.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.create.current_user_can_modify_object",
return_value=True,
)
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
data = {
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": '{"db": "prod"}',
}
result = CreateSemanticViewCommand(data).run()
assert result == mock_model
dao_view.create.assert_called_once_with(attributes=data)
def test_create_semantic_view_layer_not_found(mocker: MockerFixture) -> None:
"""Test CreateSemanticViewCommand raises when layer not found."""
dao_layer = mocker.patch(
@@ -287,11 +215,6 @@ def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
)
dao_view.validate_uniqueness.return_value = False
mocker.patch(
"superset.commands.semantic_layer.create.current_user_can_modify_object",
return_value=True,
)
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
from superset.commands.semantic_layer.exceptions import (
SemanticViewCreateFailedError,
@@ -305,110 +228,3 @@ def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
"configuration": {"db": "prod"},
}
).run()
def test_create_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test CreateSemanticViewCommand raises when the caller may not modify
the parent layer."""
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
mocker.patch(
"superset.commands.semantic_layer.create.current_user_can_modify_object",
return_value=False,
)
with pytest.raises(SemanticViewForbiddenError):
CreateSemanticViewCommand(
{
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": {"db": "prod"},
}
).run()
dao_view.create.assert_not_called()
def test_create_semantic_view_creator_of_layer_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the parent layer, but holds no explicit
editorship on it, can still add a semantic view to it."""
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
dao_view.validate_uniqueness.return_value = True
mock_model = MagicMock()
dao_view.create.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_layer.created_by = sm.current_user
result = CreateSemanticViewCommand(
{
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": {"db": "prod"},
}
).run()
assert result == mock_model
dao_view.create.assert_called_once()
def test_create_semantic_view_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the parent layer nor is an editor of
it is rejected."""
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
sm = mocker.patch(
"superset.commands.utils.security_manager",
)
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_layer.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticViewForbiddenError):
CreateSemanticViewCommand(
{
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": {"db": "prod"},
}
).run()
dao_view.create.assert_not_called()
@@ -21,11 +21,7 @@ import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
SemanticLayerNotFoundError,
)
from superset.exceptions import SupersetSecurityException
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
@@ -37,11 +33,6 @@ def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
)
dao.find_by_uuid.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=True,
)
DeleteSemanticLayerCommand("some-uuid").run()
dao.find_by_uuid.assert_called_once_with("some-uuid")
@@ -59,71 +50,6 @@ def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
DeleteSemanticLayerCommand("missing-uuid").run()
def test_delete_semantic_layer_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticLayerForbiddenError is raised for non-editors."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=False,
)
with pytest.raises(SemanticLayerForbiddenError):
DeleteSemanticLayerCommand("some-uuid").run()
dao.delete.assert_not_called()
def test_delete_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the layer, but holds no explicit editorship
on it, can still delete it."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = sm.current_user
DeleteSemanticLayerCommand("some-uuid").run()
dao.delete.assert_called_once_with([mock_model])
def test_delete_semantic_layer_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the layer nor is an editor of it is
rejected."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticLayerForbiddenError):
DeleteSemanticLayerCommand("some-uuid").run()
dao.delete.assert_not_called()
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
"""Test successful deletion of a semantic view."""
mock_model = MagicMock()
@@ -133,11 +59,10 @@ def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
)
dao.find_by_id.return_value = mock_model
# Admin (or an editor) can modify anything — no exception raised.
# Admin is owner of everything — no exception raised
mocker.patch(
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=True,
)
"superset.commands.semantic_layer.delete.security_manager"
).raise_for_editorship.return_value = None
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
@@ -151,13 +76,12 @@ def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticViewForbiddenError is raised for non-owners."""
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
from superset.exceptions import SupersetSecurityException
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
model = MagicMock()
model.created_by = None
dao.find_by_id.return_value = model
dao.find_by_id.return_value = MagicMock()
mocker.patch(
"superset.security_manager.raise_for_editorship",
@@ -168,56 +92,6 @@ def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
DeleteSemanticViewCommand(42).run()
def test_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the view, but holds no explicit editorship on
it, can still delete it."""
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = sm.current_user
DeleteSemanticViewCommand(42).run()
dao.delete.assert_called_once_with([mock_model])
def test_delete_semantic_view_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the view nor is an editor of it is
rejected."""
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticViewForbiddenError):
DeleteSemanticViewCommand(42).run()
dao.delete.assert_not_called()
def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
"""Test that SemanticViewNotFoundError is raised when view is missing."""
dao = mocker.patch(
@@ -244,9 +118,8 @@ def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
dao.find_by_ids.return_value = mock_models
mocker.patch(
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=True,
)
"superset.commands.semantic_layer.delete.security_manager"
).raise_for_editorship.return_value = None
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
@@ -260,6 +133,7 @@ def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticViewForbiddenError is raised for non-owners."""
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
from superset.exceptions import SupersetSecurityException
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
@@ -267,67 +141,14 @@ def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
dao.find_by_ids.return_value = [MagicMock(), MagicMock()]
mocker.patch(
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=False,
"superset.security_manager.raise_for_editorship",
side_effect=SupersetSecurityException(MagicMock()),
)
with pytest.raises(SemanticViewForbiddenError):
BulkDeleteSemanticViewCommand([1, 2]).run()
def test_bulk_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created every view in the batch, but holds no
explicit editorship on them, can still bulk-delete them."""
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
mock_models = [MagicMock(), MagicMock()]
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_ids.return_value = mock_models
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
for model in mock_models:
model.created_by = sm.current_user
BulkDeleteSemanticViewCommand([1, 2]).run()
dao.delete.assert_called_once_with(mock_models)
def test_bulk_delete_semantic_view_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who is neither the creator of, nor an editor for, one of
the views in the batch is rejected."""
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
mock_models = [MagicMock(), MagicMock()]
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_ids.return_value = mock_models
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
# The first view belongs to the current user, the second doesn't.
mock_models[0].created_by = sm.current_user
mock_models[1].created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticViewForbiddenError):
BulkDeleteSemanticViewCommand([1, 2]).run()
dao.delete.assert_not_called()
def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
"""Test that SemanticViewNotFoundError is raised when any id is missing."""
dao = mocker.patch(
@@ -21,7 +21,6 @@ import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticViewForbiddenError,
@@ -47,7 +46,7 @@ def test_update_semantic_view_success(mocker: MockerFixture) -> None:
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
"superset.commands.semantic_layer.update.security_manager",
)
data = {"description": "Updated", "cache_timeout": 300}
@@ -78,65 +77,18 @@ def test_update_semantic_view_forbidden(mocker: MockerFixture) -> None:
)
dao.find_by_id.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
return_value=False,
sm = mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
)
# Use a regular MagicMock for raise_for_editorship to avoid AsyncMock issues
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
with pytest.raises(SemanticViewForbiddenError):
UpdateSemanticViewCommand(1, {"description": "test"}).run()
def test_update_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the view, but holds no explicit editorship on
it, can still update it."""
mock_model = MagicMock()
mock_model.id = 1
mock_model.configuration = "{}"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
dao.update.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = sm.current_user
data = {"description": "Updated"}
result = UpdateSemanticViewCommand(1, data).run()
assert result == mock_model
dao.update.assert_called_once_with(mock_model, attributes=data)
def test_update_semantic_view_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the view nor is an editor of it is
rejected."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticViewForbiddenError):
UpdateSemanticViewCommand(1, {"description": "test"}).run()
dao.update.assert_not_called()
def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
"""Test that the command copies input data and does not mutate it."""
mock_model = MagicMock()
@@ -149,7 +101,7 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
"superset.commands.semantic_layer.update.security_manager",
)
original_data = {"description": "Original"}
@@ -175,10 +127,6 @@ def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
data = {"name": "Updated", "description": "New desc"}
result = UpdateSemanticLayerCommand("some-uuid", data).run()
@@ -198,77 +146,6 @@ def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run()
def test_update_semantic_layer_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticLayerForbiddenError is raised on ownership failure."""
mock_model = MagicMock()
mock_model.type = "snowflake"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
return_value=False,
)
with pytest.raises(SemanticLayerForbiddenError):
UpdateSemanticLayerCommand("some-uuid", {"name": "test"}).run()
dao.update.assert_not_called()
def test_update_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the layer, but holds no explicit editorship
on it, can still update it."""
mock_model = MagicMock()
mock_model.type = "snowflake"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = sm.current_user
data = {"description": "Updated"}
result = UpdateSemanticLayerCommand("some-uuid", data).run()
assert result == mock_model
dao.update.assert_called_once_with(mock_model, attributes=data)
def test_update_semantic_layer_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the layer nor is an editor of it is
rejected."""
mock_model = MagicMock()
mock_model.type = "snowflake"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticLayerForbiddenError):
UpdateSemanticLayerCommand("some-uuid", {"name": "test"}).run()
dao.update.assert_not_called()
def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
"""Test that SemanticLayerInvalidError is raised for duplicate names."""
mock_model = MagicMock()
@@ -280,10 +157,6 @@ def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
dao.find_by_uuid.return_value = mock_model
dao.validate_update_uniqueness.return_value = False
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
with pytest.raises(SemanticLayerInvalidError):
UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run()
@@ -301,10 +174,6 @@ def test_update_semantic_layer_validates_configuration(
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
mock_cls = MagicMock()
mocker.patch.dict(
"superset.commands.semantic_layer.update.registry",
@@ -330,10 +199,6 @@ def test_update_semantic_layer_skips_name_check_when_no_name(
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run()
dao.validate_update_uniqueness.assert_not_called()
@@ -350,10 +215,6 @@ def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
original_data = {"description": "Original"}
UpdateSemanticLayerCommand("some-uuid", original_data).run()
@@ -388,7 +249,7 @@ def test_update_uniqueness_different_config_same_name(
dao.validate_update_uniqueness.return_value = True
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
"superset.commands.semantic_layer.update.security_manager",
)
# Update to a config that differs from an existing view
@@ -418,7 +279,7 @@ def test_update_uniqueness_same_config_different_name(
dao.validate_update_uniqueness.return_value = True
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
"superset.commands.semantic_layer.update.security_manager",
)
data = {"name": "renamed_view", "configuration": {"schema": "prod"}}
@@ -446,7 +307,7 @@ def test_update_uniqueness_same_config_same_name_fails(
dao.validate_update_uniqueness.return_value = False
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
"superset.commands.semantic_layer.update.security_manager",
)
from superset.commands.semantic_layer.exceptions import (
-59
View File
@@ -22,13 +22,11 @@ import pytest
from superset.commands.exceptions import TagForbiddenError, TagNotFoundValidationError
from superset.commands.utils import (
current_user_can_modify_object,
Tag,
TagType,
update_tags,
validate_tags,
)
from superset.exceptions import SupersetSecurityException
from superset.tags.models import ObjectType
OBJECT_TYPES = {ObjectType.chart, ObjectType.chart}
@@ -345,60 +343,3 @@ def test_update_tags_no_tags(mock_tag_dao, object_type):
mock_tag_dao.create_custom_tagged_objects.assert_called_once_with(
object_type, 1, new_tag_names
)
@patch("superset.commands.utils.security_manager")
def test_current_user_can_modify_object_editor(mock_sm):
"""
An editor of the resource (or an admin, since ``raise_for_editorship``
treats admins as editors of everything) is allowed to modify it.
"""
mock_sm.raise_for_editorship.return_value = None
model = MagicMock()
assert current_user_can_modify_object(model) is True
@patch("superset.commands.utils.security_manager")
def test_current_user_can_modify_object_creator_fallback(mock_sm):
"""
A resource without an ``editors`` relationship (or a user who isn't in
it) still allows the object's creator through.
"""
mock_sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock())
)
model = MagicMock()
model.created_by = mock_sm.current_user
assert current_user_can_modify_object(model) is True
@patch("superset.commands.utils.security_manager")
def test_current_user_can_modify_object_denies_non_creator(mock_sm):
"""
A user who is neither an editor nor the creator is denied.
"""
mock_sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock())
)
mock_sm.current_user = MagicMock(name="current_user")
model = MagicMock()
model.created_by = MagicMock(name="someone_else")
assert current_user_can_modify_object(model) is False
@patch("superset.commands.utils.security_manager")
def test_current_user_can_modify_object_no_creator(mock_sm):
"""
A resource with no ``created_by`` set (e.g. created programmatically)
is denied to non-editors.
"""
mock_sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock())
)
model = MagicMock()
model.created_by = None
assert current_user_can_modify_object(model) is False
@@ -1,193 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from unittest.mock import MagicMock, Mock, patch
import pytest
from superset.commands.theme.exceptions import ThemeImportError
from superset.commands.theme.import_themes import import_theme
from superset.models.core import Theme
def _mock_existing(
is_system: bool = False,
is_system_default: bool = False,
is_system_dark: bool = False,
) -> MagicMock:
theme = MagicMock(spec=Theme)
theme.id = 1
theme.is_system = is_system
theme.is_system_default = is_system_default
theme.is_system_dark = is_system_dark
return theme
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_refuses_system_theme_overwrite(mock_db, mock_security_manager):
"""overwrite=True must not be able to replace a seeded system theme."""
mock_security_manager.can_access.return_value = True
existing = _mock_existing(is_system=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
with pytest.raises(ThemeImportError):
import_theme(config, overwrite=True)
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_refuses_system_default_overwrite(mock_db, mock_security_manager):
"""A non-admin overwrite=True must not replace the active default theme."""
mock_security_manager.can_access.return_value = True
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=False)
existing = _mock_existing(is_system_default=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
with pytest.raises(ThemeImportError):
import_theme(config, overwrite=True)
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_refuses_system_dark_overwrite(mock_db, mock_security_manager):
"""A non-admin overwrite=True must not replace the active dark theme."""
mock_security_manager.can_access.return_value = True
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=False)
existing = _mock_existing(is_system_dark=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
with pytest.raises(ThemeImportError):
import_theme(config, overwrite=True)
@patch("superset.utils.core.get_user")
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_admin_allows_system_default_overwrite(
mock_db, mock_security_manager, mock_get_user
):
"""An admin overwrite=True may still replace the active default theme,
mirroring UpdateThemeCommand's admin carve-out."""
mock_security_manager.can_access.return_value = True
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=True)
mock_get_user.return_value = None
existing = _mock_existing(is_system_default=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
mock_theme = MagicMock(spec=Theme)
mock_theme.id = 1
mock_import_from_dict.return_value = mock_theme
result = import_theme(config, overwrite=True)
assert result is mock_theme
assert config["id"] == existing.id
@patch("superset.utils.core.get_user")
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_admin_allows_system_dark_overwrite(
mock_db, mock_security_manager, mock_get_user
):
"""An admin overwrite=True may still replace the active dark theme,
mirroring UpdateThemeCommand's admin carve-out."""
mock_security_manager.can_access.return_value = True
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=True)
mock_get_user.return_value = None
existing = _mock_existing(is_system_dark=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
mock_theme = MagicMock(spec=Theme)
mock_theme.id = 1
mock_import_from_dict.return_value = mock_theme
result = import_theme(config, overwrite=True)
assert result is mock_theme
assert config["id"] == existing.id
@patch("superset.utils.core.get_user")
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_allows_regular_theme_overwrite(
mock_db, mock_security_manager, mock_get_user
):
"""A regular (non-system) theme can still be overwritten as before."""
mock_security_manager.can_access.return_value = True
mock_get_user.return_value = None
existing = _mock_existing()
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
mock_theme = MagicMock(spec=Theme)
mock_theme.id = 1
mock_import_from_dict.return_value = mock_theme
result = import_theme(config, overwrite=True)
assert result is mock_theme
assert config["id"] == existing.id
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_no_overwrite_returns_existing(mock_db, mock_security_manager):
"""Without overwrite=True, the existing theme is returned untouched."""
mock_security_manager.can_access.return_value = True
existing = _mock_existing(is_system_default=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
result = import_theme(config, overwrite=False)
assert result is existing
+1 -24
View File
@@ -15,7 +15,6 @@
# specific language governing permissions and limitations
# under the License.
from flask_appbuilder.security.sqla.models import User
from jinja2.exceptions import TemplateSyntaxError
from pytest import raises # noqa: PT013
from pytest_mock import MockerFixture
@@ -31,7 +30,7 @@ from superset.commands.exceptions import (
DatasourceNotFoundValidationError,
QueryNotFoundValidationError,
)
from superset.exceptions import SupersetSecurityException, SupersetTemplateException
from superset.exceptions import SupersetSecurityException
from superset.utils.core import DatasourceType, override_user
dataset_find_by_id = "superset.daos.dataset.DatasetDAO.find_by_id"
@@ -341,28 +340,6 @@ def test_query_has_access(mocker: MockerFixture) -> None:
)
def test_query_malformed_jinja_template(mocker: MockerFixture) -> None:
"""
``raise_for_access(query=...)`` Jinja-renders the query's SQL to resolve
the tables it touches. A malformed template must surface as a
``SupersetTemplateException``, not the raw ``jinja2`` exception.
"""
from superset.explore.utils import check_datasource_access
from superset.models.sql_lab import Query
mocker.patch(query_find_by_id, return_value=Query())
mocker.patch(
raise_for_access,
side_effect=TemplateSyntaxError("unexpected end of template", lineno=1),
)
with raises(SupersetTemplateException): # noqa: PT012
check_datasource_access(
datasource_id=1,
datasource_type=DatasourceType.QUERY,
)
def test_query_no_access(mocker: MockerFixture, client) -> None:
from superset.connectors.sqla.models import SqlaTable
from superset.explore.utils import check_datasource_access
@@ -36,7 +36,6 @@ from superset.commands.semantic_layer.exceptions import (
SemanticViewNotFoundError,
SemanticViewUpdateFailedError,
)
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.semantic_layers.api import SemanticLayerRestApi, SemanticViewRestApi
@@ -530,34 +529,6 @@ def test_runtime_schema_not_found(
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_runtime_schema_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST /<uuid>/schema/runtime returns 403 when access is denied."""
test_uuid = str(uuid_lib.uuid4())
mock_layer = MagicMock()
mock_layer.raise_for_access.side_effect = SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message="You don't have access to this semantic layer.",
level=ErrorLevel.ERROR,
)
)
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = mock_layer
response = client.post(
f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
)
assert response.status_code == 403
mock_layer.raise_for_access.assert_called_once()
@SEMANTIC_LAYERS_APP
def test_runtime_schema_unknown_type(
client: Any,
@@ -758,30 +729,6 @@ def test_put_semantic_layer_not_found(
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_put_semantic_layer_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test PUT /<uuid> returns 403 when the caller is not an editor."""
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
)
mock_command = mocker.patch(
"superset.semantic_layers.api.UpdateSemanticLayerCommand",
)
mock_command.return_value.run.side_effect = SemanticLayerForbiddenError()
response = client.put(
f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
json={"name": "New"},
)
assert response.status_code == 403
@SEMANTIC_LAYERS_APP
def test_put_semantic_layer_invalid(
client: Any,
@@ -874,27 +821,6 @@ def test_delete_semantic_layer_not_found(
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_delete_semantic_layer_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test DELETE /<uuid> returns 403 when the caller is not an editor."""
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
)
mock_command = mocker.patch(
"superset.semantic_layers.api.DeleteSemanticLayerCommand",
)
mock_command.return_value.run.side_effect = SemanticLayerForbiddenError()
response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
assert response.status_code == 403
@SEMANTIC_LAYERS_APP
def test_delete_semantic_layer_failed(
client: Any,
@@ -1013,33 +939,6 @@ def test_get_semantic_layer_not_found(
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_get_semantic_layer_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test GET /<uuid> returns 403 when user lacks access to the layer."""
test_uuid = uuid_lib.uuid4()
layer = MagicMock()
layer.uuid = test_uuid
layer.raise_for_access.side_effect = SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message="You don't have access to this semantic layer.",
level=ErrorLevel.ERROR,
)
)
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = layer
response = client.get(f"/api/v1/semantic_layer/{test_uuid}")
assert response.status_code == 403
layer.raise_for_access.assert_called_once()
@SEMANTIC_LAYERS_APP
def test_serialize_layer_string_config(
client: Any,
@@ -1746,35 +1645,6 @@ def test_post_semantic_view_layer_not_found(
assert result["errors"][0]["error"] == "Semantic layer not found"
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST / collects forbidden errors instead of aborting the batch."""
mock_command = mocker.patch(
"superset.semantic_layers.api.CreateSemanticViewCommand",
)
mock_command.return_value.run.side_effect = SemanticViewForbiddenError()
payload = {
"views": [
{
"name": "View 1",
"semantic_layer_uuid": str(uuid_lib.uuid4()),
"configuration": {},
},
],
}
response = client.post("/api/v1/semantic_view/", json=payload)
assert response.status_code == 422
result = response.json["result"]
assert len(result["errors"]) == 1
assert not result["created"]
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_create_failed(
client: Any,
@@ -2086,37 +1956,6 @@ def test_get_views(
assert result[1]["name"] == "View B"
@SEMANTIC_LAYERS_APP
def test_get_views_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST /<uuid>/views returns 403 when access is denied."""
test_uuid = str(uuid_lib.uuid4())
mock_layer = MagicMock()
mock_layer.uuid = uuid_lib.uuid4()
mock_layer.raise_for_access.side_effect = SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message="You don't have access to this semantic layer.",
level=ErrorLevel.ERROR,
)
)
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = mock_layer
response = client.post(
f"/api/v1/semantic_layer/{test_uuid}/views",
json={"runtime_data": {"database": "mydb"}},
)
assert response.status_code == 403
mock_layer.raise_for_access.assert_called_once()
mock_layer.implementation.get_semantic_views.assert_not_called()
@SEMANTIC_LAYERS_APP
def test_get_views_with_existing(
client: Any,
@@ -1100,90 +1100,6 @@ def test_semantic_layer_get_perm_special_characters() -> None:
)
# =============================================================================
# SemanticLayer.raise_for_access tests
# =============================================================================
def test_semantic_layer_raise_for_access_all_datasources(app: Any) -> None:
"""Test raise_for_access passes when user has all_datasource_access."""
from superset import security_manager
layer = SemanticLayer()
layer.name = "Layer"
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
layer.perm = layer.get_perm()
with patch.object(
security_manager, "can_access_all_datasources", return_value=True
):
layer.raise_for_access()
def test_semantic_layer_raise_for_access_perm(app: Any) -> None:
"""Test raise_for_access passes when user has datasource_access to the
layer's perm."""
from superset import security_manager
layer = SemanticLayer()
layer.name = "Layer"
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
layer.perm = layer.get_perm()
with (
patch.object(
security_manager, "can_access_all_datasources", return_value=False
),
patch.object(
security_manager, "can_access", return_value=True
) as mock_can_access,
):
layer.raise_for_access()
mock_can_access.assert_called_once_with("datasource_access", layer.perm)
def test_semantic_layer_raise_for_access_denied(app: Any) -> None:
"""Test raise_for_access raises SupersetSecurityException when denied."""
from superset import security_manager
from superset.exceptions import SupersetSecurityException
layer = SemanticLayer()
layer.name = "Layer"
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
layer.perm = layer.get_perm()
with (
patch.object(
security_manager, "can_access_all_datasources", return_value=False
),
patch.object(security_manager, "can_access", return_value=False),
):
with pytest.raises(SupersetSecurityException):
layer.raise_for_access()
def test_semantic_layer_raise_for_access_no_perm_denied(app: Any) -> None:
"""Test raise_for_access raises SupersetSecurityException when the layer
has no perm set, without even attempting a datasource_access check."""
from superset import security_manager
from superset.exceptions import SupersetSecurityException
layer = SemanticLayer()
layer.name = "Layer"
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
layer.perm = None
with (
patch.object(
security_manager, "can_access_all_datasources", return_value=False
),
patch.object(security_manager, "can_access") as mock_can_access,
):
with pytest.raises(SupersetSecurityException):
layer.raise_for_access()
mock_can_access.assert_not_called()
# =============================================================================
# SemanticView.raise_for_access tests
# =============================================================================
-51
View File
@@ -20,7 +20,6 @@ from unittest.mock import Mock, patch
import pytest
from superset.commands.theme.exceptions import (
SystemThemeInUseError,
SystemThemeProtectedError,
ThemeNotFoundError,
)
@@ -63,8 +62,6 @@ class TestUpdateThemeCommand:
# Arrange
mock_theme = Mock(spec=Theme)
mock_theme.is_system = False
mock_theme.is_system_default = False
mock_theme.is_system_dark = False
mock_theme_dao.find_by_id.return_value = mock_theme
command = UpdateThemeCommand(123, {"theme_name": "test"})
@@ -80,8 +77,6 @@ class TestUpdateThemeCommand:
# Arrange
mock_theme = Mock(spec=Theme)
mock_theme.is_system = False
mock_theme.is_system_default = False
mock_theme.is_system_dark = False
mock_updated_theme = Mock(spec=Theme)
mock_theme_dao.find_by_id.return_value = mock_theme
mock_theme_dao.update.return_value = mock_updated_theme
@@ -97,52 +92,6 @@ class TestUpdateThemeCommand:
mock_theme, {"theme_name": "updated_name"}
)
@patch("superset.commands.theme.update.security_manager")
@patch("superset.commands.theme.update.ThemeDAO")
def test_validate_system_default_theme_blocks_non_admin(
self, mock_theme_dao, mock_security_manager
):
"""Non-admins cannot update the active system-default/dark theme slot."""
mock_theme = Mock(spec=Theme)
mock_theme.is_system = False
mock_theme.is_system_default = True
mock_theme.is_system_dark = False
mock_theme_dao.find_by_id.return_value = mock_theme
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=False)
command = UpdateThemeCommand(123, {"json_data": "{}"})
with pytest.raises(SystemThemeInUseError):
command.validate()
@patch("superset.commands.theme.update.security_manager")
@patch("superset.commands.theme.update.ThemeDAO")
def test_validate_system_default_theme_allows_admin(
self, mock_theme_dao, mock_security_manager
):
"""Admins can still update the active system-default/dark theme slot."""
mock_theme = Mock(spec=Theme)
mock_theme.is_system = False
mock_theme.is_system_default = True
mock_theme.is_system_dark = False
mock_theme_dao.find_by_id.return_value = mock_theme
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=True)
command = UpdateThemeCommand(123, {"json_data": "{}"})
command.validate() # Should not raise any exception
assert command._model == mock_theme
def test_theme_is_gamma_read_only():
"""Theme writes must require at least Alpha; Gamma only gets read access."""
from superset import security_manager
assert "Theme" in security_manager.GAMMA_READ_ONLY_MODEL_VIEWS
class TestSeedSystemThemesCommand:
"""Unit tests for SeedSystemThemesCommand"""
+1 -17
View File
@@ -1942,29 +1942,13 @@ def test_sanitize_svg_content_safe():
def test_sanitize_svg_content_removes_scripts():
"""Test that dangerous script content is removed."""
"""Test that nh3 removes dangerous script content."""
malicious_svg = '<svg><script>alert("xss")</script><rect/></svg>'
result = sanitize_svg_content(malicious_svg)
assert "script" not in result.lower()
assert "alert" not in result
def test_sanitize_svg_content_removes_script_with_attributes_on_closer():
"""A closing </script foo> tag is still a valid closer to browsers."""
malicious_svg = "<svg><script>fetch('/api/v1/me/')</script foo></svg>"
result = sanitize_svg_content(malicious_svg)
assert "script" not in result.lower()
assert "fetch" not in result
def test_sanitize_svg_content_removes_unterminated_script():
"""An unterminated <script> opener with no closing tag is still stripped."""
malicious_svg = "<svg><script>alert('xss')"
result = sanitize_svg_content(malicious_svg)
assert "script" not in result.lower()
assert "alert" not in result
def test_sanitize_url_relative():
"""Test that relative URLs are allowed."""
assert sanitize_url("/static/spinner.gif") == "/static/spinner.gif"
@@ -188,7 +188,6 @@ def test_terminal_event_clears_transaction_state(
listener.ACTION_META_KEY: {"headline": "restored"},
listener._INITIAL_STATES_KEY: {("chart", 7): object()},
listener._FINALIZING_KEY: True,
listener.NORMALIZATION_CONTEXT_KEY: {"pending": True},
"unrelated": "preserved",
}
)
@@ -1,186 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from sqlalchemy.orm import Session
from superset.versioning.changes.normalization import (
consume_normalization_context,
filter_normalization_records,
matching_normalization_context,
MAX_NORMALIZATION_TRANSITIONS,
NormalizationContext,
NormalizationTransition,
sanitize_normalization_changes,
store_normalization_context,
)
from superset.versioning.diff import ChangeRecord
def _transition(
control: str = "row_limit", from_value: object = None, to_value: object = 10000
) -> dict[str, object]:
return {
"control": control,
"from_present": True,
"from_value": from_value,
"to_present": True,
"to_value": to_value,
}
def test_sanitizer_preserves_missing_and_null_as_distinct_states() -> None:
missing: dict[str, object] = {
"control": "show_legend",
"from_present": False,
"to_present": True,
"to_value": True,
}
null: dict[str, object] = _transition("row_limit")
transitions: tuple[NormalizationTransition, ...] = sanitize_normalization_changes(
[missing, null]
)
assert len(transitions) == 2
assert not transitions[0].from_present
assert transitions[1].from_present
assert transitions[1].from_value is None
def test_sanitizer_ignores_bad_entries_but_rejects_duplicate_envelope() -> None:
assert len(sanitize_normalization_changes([{"bad": True}, _transition()])) == 1
assert sanitize_normalization_changes([_transition(), _transition()]) == ()
assert sanitize_normalization_changes({"not": "a list"}) == ()
def test_sanitizer_rejects_bounded_envelope_failures() -> None:
too_many: list[dict[str, object]] = [
_transition(control=f"control_{index}")
for index in range(MAX_NORMALIZATION_TRANSITIONS + 1)
]
deep_value: object = None
for _index in range(22):
deep_value = [deep_value]
assert sanitize_normalization_changes(too_many) == ()
assert sanitize_normalization_changes([_transition(control="x" * 257)]) == ()
assert sanitize_normalization_changes([_transition(from_value=deep_value)]) == ()
assert sanitize_normalization_changes([object()]) == ()
def test_matching_requires_exact_presence_and_json_value_types() -> None:
raw: list[dict[str, object]] = [
{
"control": "show_legend",
"from_present": False,
"to_present": True,
"to_value": True,
},
_transition(),
]
context: NormalizationContext | None = matching_normalization_context(
7, raw, {"row_limit": None}, {"show_legend": True, "row_limit": 10000}
)
assert context is not None
assert {item.control for item in context.transitions} == {
"show_legend",
"row_limit",
}
assert (
matching_normalization_context(
7,
[_transition(from_value=True, to_value=2)],
{"row_limit": 1},
{"row_limit": 2},
)
is None
)
def test_filter_returns_fresh_records_without_matching_params_control() -> None:
records: list[ChangeRecord] = [
ChangeRecord("field", "edit", ["params", "row_limit"], None, 10000),
ChangeRecord("field", "edit", ["slice_name"], "Old", "New"),
]
context: NormalizationContext | None = matching_normalization_context(
7, [_transition()], {"row_limit": None}, {"row_limit": 10000}
)
filtered: list[ChangeRecord] = filter_normalization_records(records, context)
assert filtered == [records[1]]
assert filtered is not records
def test_context_is_consumed_once_and_same_chart_ambiguity_fails_open() -> None:
session: Session = Session()
context: NormalizationContext | None = matching_normalization_context(
7, [_transition()], {"row_limit": None}, {"row_limit": 10000}
)
assert context is not None
store_normalization_context(session, context)
assert consume_normalization_context(session, 7) == context
assert consume_normalization_context(session, 7) is None
store_normalization_context(session, context)
store_normalization_context(session, context)
assert consume_normalization_context(session, 7) is None
def test_drop_transition_matches_and_filters_a_remove_record() -> None:
"""A stash-time drop (present -> absent) suppresses its remove record."""
raw: list[dict[str, object]] = [
{
"control": "order_desc",
"from_present": True,
"from_value": True,
"to_present": False,
},
]
context: NormalizationContext | None = matching_normalization_context(
7, raw, {"order_desc": True, "row_limit": 100}, {"row_limit": 100}
)
assert context is not None
assert [item.control for item in context.transitions] == ["order_desc"]
records: list[ChangeRecord] = [
ChangeRecord("field", "remove", ["params", "order_desc"], True, None),
ChangeRecord("field", "edit", ["params", "row_limit"], 100, 50),
]
assert filter_normalization_records(records, context) == [records[1]]
def test_drop_transition_requires_the_key_to_be_absent_after() -> None:
"""A drop advisory does not match when the key survived the save."""
raw: list[dict[str, object]] = [
{
"control": "order_desc",
"from_present": True,
"from_value": True,
"to_present": False,
},
]
assert (
matching_normalization_context(
7, raw, {"order_desc": True}, {"order_desc": False}
)
is None
)