mirror of
https://github.com/apache/superset.git
synced 2026-06-10 18:19:28 +00:00
Compare commits
5 Commits
dependabot
...
fix/smtp-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc6fe3a76d | ||
|
|
42d6e5217a | ||
|
|
4280db194c | ||
|
|
45f0743e1a | ||
|
|
f27424d72e |
12
UPDATING.md
12
UPDATING.md
@@ -70,6 +70,18 @@ superset revoke-guest-tokens
|
||||
|
||||
This change is backward compatible. The feature is off by default, and even when enabled nothing is revoked until an admin explicitly bumps the version: the expected version starts at `0`, and tokens minted before this change (which carry no version claim) are treated as version `0`. No database migration is required.
|
||||
|
||||
### SMTP server certificate validation enabled by default
|
||||
|
||||
`SMTP_SSL_SERVER_AUTH` now defaults to `True` (previously `False`). With this default, STARTTLS/SSL connections to the configured SMTP server validate the server's TLS certificate against the system trusted CA store. This makes outbound email (alerts and reports) verify the mail server's identity out of the box.
|
||||
|
||||
If your SMTP server presents a self-signed certificate, or a certificate that is not trusted by the system CA store, email delivery may now fail with a certificate verification error. To restore the previous behavior of skipping certificate validation, set the following in `superset_config.py`:
|
||||
|
||||
```python
|
||||
SMTP_SSL_SERVER_AUTH = False
|
||||
```
|
||||
|
||||
The recommended fix is to add the SMTP server's certificate (or its issuing CA) to the system trust store rather than disabling validation.
|
||||
|
||||
### Dataset import validates catalog against the target connection
|
||||
|
||||
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
|
||||
|
||||
@@ -41,11 +41,21 @@ jest.mock('react-redux', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(useSelector as jest.Mock).mockImplementation(selector => {
|
||||
if (selector.name === 'useActiveDashboardTabs') {
|
||||
return ['TAB_1'];
|
||||
}
|
||||
return [];
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: ['TAB_1'] },
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
'CHART-123': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 123 },
|
||||
parents: ['ROOT_ID', 'TAB_1'],
|
||||
},
|
||||
TAB_1: { type: 'TAB', id: 'TAB_1' },
|
||||
},
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +109,14 @@ test('useIsFilterInScope should return false for filters with inactive rootPath'
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope should return all filters in scope when no tabs exist', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: {} },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter_1',
|
||||
@@ -601,3 +619,479 @@ test('useChartCustomizationConfiguration ignores undefined items in metadata', (
|
||||
expect.objectContaining({ id: 'CHART_CUSTOMIZATION-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
// --- Embedded / hideTab: activeTabs is empty ---
|
||||
// When an embedded dashboard uses hideTab:true, the Tabs component never
|
||||
// mounts, so setActiveTab never fires and activeTabs stays []. The same
|
||||
// empty state occurs transiently on first render of any tabbed dashboard.
|
||||
//
|
||||
// useActiveDashboardTabs derives the default first tab from the layout when
|
||||
// Redux activeTabs is empty, so scope evaluation uses the correct default
|
||||
// tab instead of either "no tabs active" (blank filter bar) or "all tabs"
|
||||
// (showing out-of-scope filters).
|
||||
|
||||
// Helper: build a layout with ROOT_ID → TABS container → TAB children
|
||||
function embeddedLayout(extras: Record<string, Record<string, unknown>> = {}) {
|
||||
return {
|
||||
ROOT_ID: {
|
||||
type: 'ROOT',
|
||||
id: 'ROOT_ID',
|
||||
children: ['TABS-1'],
|
||||
},
|
||||
'TABS-1': {
|
||||
type: 'TABS',
|
||||
id: 'TABS-1',
|
||||
children: ['TAB-Company', 'TAB-Desktop'],
|
||||
},
|
||||
'TAB-Company': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Company',
|
||||
children: ['CHART-1', 'CHART-2'],
|
||||
},
|
||||
'TAB-Desktop': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Desktop',
|
||||
children: ['CHART-3'],
|
||||
},
|
||||
'CHART-1': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 1 },
|
||||
parents: ['ROOT_ID', 'TAB-Company'],
|
||||
},
|
||||
'CHART-2': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 2 },
|
||||
parents: ['ROOT_ID', 'TAB-Company'],
|
||||
},
|
||||
'CHART-3': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 3 },
|
||||
parents: ['ROOT_ID', 'TAB-Desktop'],
|
||||
},
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
test('useIsFilterInScope: filter scoped to default tab is in-scope when activeTabs is empty', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_default_tab',
|
||||
name: 'Default Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1, 2],
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter scoped to default (first) tab',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: filter scoped only to non-default tab is out-of-scope when activeTabs is empty', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_other_tab',
|
||||
name: 'Other Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [3],
|
||||
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter scoped only to non-default tab',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(false);
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: filter with rootPath to default tab is in-scope when activeTabs is empty', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_rootpath_default',
|
||||
name: 'RootPath Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
|
||||
description: 'Filter using rootPath to default tab',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope: only default-tab filters are in scope when activeTabs is empty (embedded hideTab)', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter_company',
|
||||
name: 'Company Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1, 2],
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'survey_rating' }, datasetId: 1 }],
|
||||
description: 'Filter scoped to default tab',
|
||||
},
|
||||
{
|
||||
id: 'filter_desktop_only',
|
||||
name: 'Desktop Only Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [3],
|
||||
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'pool_name' }, datasetId: 2 }],
|
||||
description: 'Filter scoped only to non-default tab',
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useSelectFiltersInScope(filters));
|
||||
expect(result.current[0]).toHaveLength(1);
|
||||
expect(result.current[0][0]).toEqual(
|
||||
expect.objectContaining({ id: 'filter_company' }),
|
||||
);
|
||||
expect(result.current[1]).toHaveLength(1);
|
||||
expect(result.current[1][0]).toEqual(
|
||||
expect.objectContaining({ id: 'filter_desktop_only' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope: dividers are always in scope even when activeTabs is empty', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const items: (Filter | Divider)[] = [
|
||||
{
|
||||
id: 'divider_embedded',
|
||||
type: NativeFilterType.Divider,
|
||||
title: 'Section',
|
||||
description: 'Divider in embedded mode',
|
||||
},
|
||||
{
|
||||
id: 'filter_default',
|
||||
name: 'Default Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1],
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter in default tab',
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useSelectFiltersInScope(items));
|
||||
expect(result.current[0]).toHaveLength(2);
|
||||
expect(result.current[1]).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('useSelectFiltersInScope: correctly scopes chartsInScope filters when activeTabs is populated', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: ['TAB-Company'] },
|
||||
dashboardLayout: { present: embeddedLayout() },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter_active_tab',
|
||||
name: 'Active Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1],
|
||||
scope: { rootPath: ['TAB-Company'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter scoped to active tab',
|
||||
},
|
||||
{
|
||||
id: 'filter_inactive_tab',
|
||||
name: 'Inactive Tab Filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [3],
|
||||
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 2 }],
|
||||
description: 'Filter scoped to inactive tab',
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useSelectFiltersInScope(filters));
|
||||
expect(result.current[0]).toHaveLength(1);
|
||||
expect(result.current[0][0]).toEqual(
|
||||
expect.objectContaining({ id: 'filter_active_tab' }),
|
||||
);
|
||||
expect(result.current[1]).toHaveLength(1);
|
||||
expect(result.current[1][0]).toEqual(
|
||||
expect.objectContaining({ id: 'filter_inactive_tab' }),
|
||||
);
|
||||
});
|
||||
|
||||
// Layout-derived default-tab fallback edge cases. These pin behavior of
|
||||
// useActiveDashboardTabs when activeTabs is empty across structural variants
|
||||
// of dashboardLayout, so the fallback can't silently regress.
|
||||
|
||||
test('useIsFilterInScope: dashboard with no top-level tabs (root child is GRID_ID) treats filters as in-scope', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: { type: 'GRID', id: 'GRID_ID', children: ['CHART-1'] },
|
||||
'CHART-1': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 1 },
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_no_tabs',
|
||||
name: 'No-tabs filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter on a no-tabs dashboard',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
// Chart has no TAB ancestors → tabParents is empty → considered in-scope.
|
||||
expect(result.current(filter)).toBe(true);
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: missing dashboardLayout falls back without crashing', () => {
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
|
||||
const mockState = {
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: undefined },
|
||||
};
|
||||
return selector(mockState);
|
||||
});
|
||||
|
||||
const filter: Filter = {
|
||||
id: 'filter_no_layout',
|
||||
name: 'No layout',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [1],
|
||||
scope: { rootPath: ['TAB-A'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter when layout is missing',
|
||||
};
|
||||
|
||||
// The hook should not throw when layout is missing. Without the
|
||||
// useActiveDashboardTabs guard, indexing dashboardLayout[ROOT_ID] would
|
||||
// crash here.
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
expect(() => result.current(filter)).not.toThrow();
|
||||
});
|
||||
|
||||
// Shared fixture for the two nested-tabs tests below. Layout is identical;
|
||||
// only the redux activeTabs differs (empty for the default-path test,
|
||||
// inner-only for the hideTab ancestor-merge test).
|
||||
const nestedTabsLayout = () => ({
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['TABS-1'] },
|
||||
'TABS-1': {
|
||||
type: 'TABS',
|
||||
id: 'TABS-1',
|
||||
children: ['TAB-Outer1', 'TAB-Outer2'],
|
||||
},
|
||||
'TAB-Outer1': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Outer1',
|
||||
children: ['TABS-2'],
|
||||
},
|
||||
'TAB-Outer2': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Outer2',
|
||||
children: ['CHART-Outer2'],
|
||||
},
|
||||
'TABS-2': {
|
||||
type: 'TABS',
|
||||
id: 'TABS-2',
|
||||
children: ['TAB-Inner1', 'TAB-Inner2'],
|
||||
},
|
||||
'TAB-Inner1': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Inner1',
|
||||
children: ['CHART-Inner1'],
|
||||
},
|
||||
'TAB-Inner2': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-Inner2',
|
||||
children: ['CHART-Inner2'],
|
||||
},
|
||||
'CHART-Inner1': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 11 },
|
||||
parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner1'],
|
||||
},
|
||||
'CHART-Inner2': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 12 },
|
||||
parents: ['ROOT_ID', 'TAB-Outer1', 'TABS-2', 'TAB-Inner2'],
|
||||
},
|
||||
'CHART-Outer2': {
|
||||
type: 'CHART',
|
||||
meta: { chartId: 20 },
|
||||
parents: ['ROOT_ID', 'TAB-Outer2'],
|
||||
},
|
||||
});
|
||||
|
||||
const mockNestedTabsState = (activeTabs: string[]) => ({
|
||||
dashboardState: { activeTabs },
|
||||
dashboardLayout: { present: nestedTabsLayout() },
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: deeply nested tabs — default path includes inner-tab default', () => {
|
||||
// ROOT → TABS-1 → [TAB-Outer1, TAB-Outer2]
|
||||
// └─ TAB-Outer1 → TABS-2 → [TAB-Inner1, TAB-Inner2]
|
||||
// Default path should be ['TAB-Outer1', 'TAB-Inner1'].
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
|
||||
selector(mockNestedTabsState([])),
|
||||
);
|
||||
|
||||
const innerDefaultFilter: Filter = {
|
||||
id: 'filter_inner1',
|
||||
name: 'Inner default-tab filter',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [11],
|
||||
scope: { rootPath: ['TAB-Inner1'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter scoped to inner default tab',
|
||||
};
|
||||
|
||||
const innerNonDefaultFilter: Filter = {
|
||||
...innerDefaultFilter,
|
||||
id: 'filter_inner2',
|
||||
chartsInScope: [12],
|
||||
scope: { rootPath: ['TAB-Inner2'], excluded: [] },
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
// CHART-Inner1's tab parents [TAB-Outer1, TAB-Inner1] are both in default
|
||||
// path → in scope.
|
||||
expect(result.current(innerDefaultFilter)).toBe(true);
|
||||
// CHART-Inner2's tab parent TAB-Inner2 is not in default path → out of scope.
|
||||
expect(result.current(innerNonDefaultFilter)).toBe(false);
|
||||
});
|
||||
|
||||
test('useIsFilterInScope: nested Tabs mounted under hideTab:true — outer ancestor merged so outer-tab scoping is preserved', () => {
|
||||
// hideTab:true skips the top-level Tabs but a nested Tabs can still mount
|
||||
// and dispatch setActiveTab. activeTabs holds only the inner id; without
|
||||
// ancestor merging, filters whose charts have tabParents=[outer, inner]
|
||||
// would be marked out-of-scope because the outer id is missing.
|
||||
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
|
||||
selector(mockNestedTabsState(['TAB-Inner1'])),
|
||||
);
|
||||
|
||||
const innerActiveFilter: Filter = {
|
||||
id: 'filter_inner1_active',
|
||||
name: 'Filter scoped to active inner tab',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [11],
|
||||
scope: { rootPath: ['TAB-Inner1'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 1 }],
|
||||
description: 'Filter on the active inner tab',
|
||||
};
|
||||
|
||||
const otherOuterFilter: Filter = {
|
||||
id: 'filter_other_outer',
|
||||
name: 'Filter scoped to non-default outer tab',
|
||||
filterType: 'filter_select',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [20],
|
||||
scope: { rootPath: ['TAB-Outer2'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: {},
|
||||
cascadeParentIds: [],
|
||||
targets: [{ column: { name: 'col' }, datasetId: 2 }],
|
||||
description: 'Filter on the other outer tab — must stay out of scope',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useIsFilterInScope());
|
||||
// Outer ancestor TAB-Outer1 is merged into the active path → in scope.
|
||||
expect(result.current(innerActiveFilter)).toBe(true);
|
||||
// TAB-Outer2 is not in the active path → out of scope, scoping preserved.
|
||||
expect(result.current(otherOuterFilter)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { FilterElement } from './FilterBar/FilterControls/types';
|
||||
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
|
||||
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
|
||||
import { CHART_TYPE, TAB_TYPE, TABS_TYPE } from '../../util/componentTypes';
|
||||
import { DASHBOARD_ROOT_ID } from '../../util/constants';
|
||||
import { isChartCustomizationId } from './FiltersConfigModal/utils';
|
||||
import {
|
||||
migrateChartCustomizationArray,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
} from '../../util/migrateChartCustomization';
|
||||
|
||||
const EMPTY_ARRAY: ChartCustomizationConfiguration = [];
|
||||
const EMPTY_ACTIVE_TABS: ActiveTabs = [];
|
||||
const defaultFilterConfiguration: (Filter | Divider)[] = [];
|
||||
|
||||
export const selectFilterConfiguration: (
|
||||
@@ -175,22 +177,83 @@ export function useDashboardHasTabs() {
|
||||
const dashboardLayout = useDashboardLayout();
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(dashboardLayout).some(element => element.type === TAB_TYPE),
|
||||
dashboardLayout
|
||||
? Object.values(dashboardLayout).some(
|
||||
element => element.type === TAB_TYPE,
|
||||
)
|
||||
: false,
|
||||
[dashboardLayout],
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveDashboardTabs() {
|
||||
return useSelector<RootState, ActiveTabs>(
|
||||
function useActiveDashboardTabs(): ActiveTabs {
|
||||
const reduxTabs = useSelector<RootState, ActiveTabs>(
|
||||
state => state.dashboardState?.activeTabs,
|
||||
);
|
||||
const dashboardLayout = useDashboardLayout();
|
||||
|
||||
return useMemo(() => {
|
||||
const reduxList = reduxTabs ?? [];
|
||||
const reduxFallback = reduxList.length ? reduxList : EMPTY_ACTIVE_TABS;
|
||||
if (!dashboardLayout) return reduxFallback;
|
||||
|
||||
// Tabbed dashboards always nest the top-level TABS container as the first
|
||||
// child of ROOT. If that invariant doesn't hold (no-tabs layout), no
|
||||
// fallback applies and we use reduxTabs as-is.
|
||||
const root = dashboardLayout[DASHBOARD_ROOT_ID];
|
||||
if (!root?.children?.length) return reduxFallback;
|
||||
const topContainer = dashboardLayout[root.children[0]];
|
||||
if (topContainer?.type !== TABS_TYPE || !topContainer.children?.length) {
|
||||
return reduxFallback;
|
||||
}
|
||||
|
||||
// Walk every TABS container along the active path. For each container,
|
||||
// pick the child Redux marked active; otherwise pick the first child (the
|
||||
// default the live Tabs component would render). This handles:
|
||||
// - empty reduxTabs (hideTab:true, no permalink) → full default path
|
||||
// - reduxTabs missing an outer ancestor (hideTab:true skipped the
|
||||
// top-level Tabs, but a nested Tabs dispatched setActiveTab) → fill
|
||||
// in the missing ancestor so outer-tab scoping is preserved
|
||||
// - fully populated reduxTabs (normal hydration) → same result
|
||||
const reduxSet = new Set(reduxList);
|
||||
const result: ActiveTabs = [];
|
||||
const queue: string[] = [
|
||||
topContainer.children.find(c => reduxSet.has(c)) ??
|
||||
topContainer.children[0],
|
||||
];
|
||||
while (queue.length > 0) {
|
||||
const tabId = queue.shift()!;
|
||||
result.push(tabId);
|
||||
const tab = dashboardLayout[tabId];
|
||||
if (!tab?.children) continue;
|
||||
for (const childId of tab.children) {
|
||||
const child = dashboardLayout[childId];
|
||||
if (child?.type !== TABS_TYPE || !child.children?.length) continue;
|
||||
queue.push(
|
||||
child.children.find(c => reduxSet.has(c)) ?? child.children[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve any reduxTabs entries that fell outside the traversed path so
|
||||
// we never silently drop a redux-marked active tab id.
|
||||
const resultSet = new Set(result);
|
||||
for (const id of reduxList) {
|
||||
if (!resultSet.has(id)) result.push(id);
|
||||
}
|
||||
return result;
|
||||
}, [reduxTabs, dashboardLayout]);
|
||||
}
|
||||
|
||||
function useSelectChartTabParents() {
|
||||
const dashboardLayout = useDashboardLayout();
|
||||
const layoutChartItems = useMemo(
|
||||
() =>
|
||||
Object.values(dashboardLayout).filter(item => item.type === CHART_TYPE),
|
||||
dashboardLayout
|
||||
? Object.values(dashboardLayout).filter(
|
||||
item => item.type === CHART_TYPE,
|
||||
)
|
||||
: [],
|
||||
[dashboardLayout],
|
||||
);
|
||||
return useCallback(
|
||||
@@ -199,7 +262,7 @@ function useSelectChartTabParents() {
|
||||
layoutItem => layoutItem.meta?.chartId === chartId,
|
||||
);
|
||||
return chartLayoutItem?.parents?.filter(
|
||||
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
|
||||
(parent: string) => dashboardLayout?.[parent]?.type === TAB_TYPE,
|
||||
);
|
||||
},
|
||||
[dashboardLayout, layoutChartItems],
|
||||
@@ -332,6 +395,7 @@ export function useSelectCustomizationsInScope(
|
||||
| ChartCustomizationDivider
|
||||
)[] = [];
|
||||
|
||||
// we check customization scopes only on dashboards with tabs
|
||||
if (!dashboardHasTabs) {
|
||||
customizationsInScope = customizations;
|
||||
} else {
|
||||
|
||||
@@ -1684,9 +1684,14 @@ SMTP_USER = "superset"
|
||||
SMTP_PORT = 25
|
||||
SMTP_PASSWORD = "superset" # noqa: S105
|
||||
SMTP_MAIL_FROM = "superset@superset.com"
|
||||
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
|
||||
# default system root CA certificates.
|
||||
SMTP_SSL_SERVER_AUTH = False
|
||||
# If True creates a default SSL context with ssl.Purpose.SERVER_AUTH using the
|
||||
# default system root CA certificates. This makes STARTTLS/SSL connections to the
|
||||
# SMTP server validate the server's certificate against the trusted CA store.
|
||||
# Defaults to True so the mail server identity is verified out of the box. Set to
|
||||
# False to restore the previous behavior of skipping certificate validation (for
|
||||
# example, when using a self-signed certificate that is not in the system CA
|
||||
# store).
|
||||
SMTP_SSL_SERVER_AUTH = True
|
||||
ENABLE_CHUNK_ENCODING = False
|
||||
|
||||
# Whether to bump the logging level to ERROR on the flask_appbuilder package
|
||||
|
||||
@@ -208,6 +208,7 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
@mock.patch("smtplib.SMTP")
|
||||
def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
|
||||
current_app.config["SMTP_SSL"] = True
|
||||
current_app.config["SMTP_SSL_SERVER_AUTH"] = False
|
||||
mock_smtp.return_value = mock.Mock()
|
||||
mock_smtp_ssl.return_value = mock.Mock()
|
||||
utils.send_mime_email(
|
||||
|
||||
@@ -312,3 +312,119 @@ def test_full_setting(
|
||||
assert dttm_col.is_dttm
|
||||
assert dttm_col.python_date_format == "epoch_s"
|
||||
assert dttm_col.expression == "CAST(dttm as INTEGER)"
|
||||
|
||||
|
||||
def test_smtp_ssl_server_auth_defaults_to_true() -> None:
|
||||
"""
|
||||
The shipped default for SMTP_SSL_SERVER_AUTH validates the SMTP server's
|
||||
TLS certificate. Operators can still opt out by overriding it to False.
|
||||
"""
|
||||
from superset import config
|
||||
|
||||
assert config.SMTP_SSL_SERVER_AUTH is True
|
||||
|
||||
|
||||
def _smtp_config(**overrides: Any) -> dict[str, Any]:
|
||||
config = {
|
||||
"SMTP_HOST": "localhost",
|
||||
"SMTP_PORT": 25,
|
||||
"SMTP_USER": "",
|
||||
"SMTP_PASSWORD": "",
|
||||
"SMTP_STARTTLS": False,
|
||||
"SMTP_SSL": False,
|
||||
"SMTP_SSL_SERVER_AUTH": True,
|
||||
}
|
||||
config.update(overrides)
|
||||
return config
|
||||
|
||||
|
||||
def test_send_mime_email_ssl_server_auth_passes_context(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
With SMTP_SSL and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
|
||||
default SSL context and threads it through to ``smtplib.SMTP_SSL`` so the
|
||||
server certificate is validated.
|
||||
"""
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from superset.utils import core as utils
|
||||
|
||||
create_default_context = mocker.patch(
|
||||
"superset.utils.core.ssl.create_default_context"
|
||||
)
|
||||
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
|
||||
smtp = mocker.patch("smtplib.SMTP")
|
||||
|
||||
utils.send_mime_email(
|
||||
"from",
|
||||
["to"],
|
||||
MIMEMultipart(),
|
||||
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=True),
|
||||
dryrun=False,
|
||||
)
|
||||
|
||||
create_default_context.assert_called_once_with()
|
||||
assert not smtp.called
|
||||
smtp_ssl.assert_called_once_with(
|
||||
"localhost", 25, context=create_default_context.return_value
|
||||
)
|
||||
|
||||
|
||||
def test_send_mime_email_starttls_server_auth_passes_context(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
With STARTTLS and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
|
||||
default SSL context and threads it through to ``starttls`` so the server
|
||||
certificate is validated.
|
||||
"""
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from superset.utils import core as utils
|
||||
|
||||
create_default_context = mocker.patch(
|
||||
"superset.utils.core.ssl.create_default_context"
|
||||
)
|
||||
smtp = mocker.patch("smtplib.SMTP")
|
||||
|
||||
utils.send_mime_email(
|
||||
"from",
|
||||
["to"],
|
||||
MIMEMultipart(),
|
||||
_smtp_config(SMTP_STARTTLS=True, SMTP_SSL_SERVER_AUTH=True),
|
||||
dryrun=False,
|
||||
)
|
||||
|
||||
create_default_context.assert_called_once_with()
|
||||
smtp.return_value.starttls.assert_called_once_with(
|
||||
context=create_default_context.return_value
|
||||
)
|
||||
|
||||
|
||||
def test_send_mime_email_server_auth_disabled_skips_context(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
When SMTP_SSL_SERVER_AUTH is disabled no SSL context is built and ``None`` is
|
||||
passed through, preserving the opt-out (certificate validation skipped).
|
||||
"""
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from superset.utils import core as utils
|
||||
|
||||
create_default_context = mocker.patch(
|
||||
"superset.utils.core.ssl.create_default_context"
|
||||
)
|
||||
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
|
||||
|
||||
utils.send_mime_email(
|
||||
"from",
|
||||
["to"],
|
||||
MIMEMultipart(),
|
||||
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=False),
|
||||
dryrun=False,
|
||||
)
|
||||
|
||||
assert not create_default_context.called
|
||||
smtp_ssl.assert_called_once_with("localhost", 25, context=None)
|
||||
|
||||
Reference in New Issue
Block a user