Compare commits

...

5 Commits

Author SHA1 Message Date
Evan
3b3d3a9019 test(config): cover SMTP_SSL_SERVER_AUTH enabled behavior
Add unit tests in config_test.py that exercise send_mime_email and assert
ssl.create_default_context() is called and its context is threaded through
to SMTP_SSL and starttls when SMTP_SSL_SERVER_AUTH=True, plus the opt-out
path passing context=None. Complements the existing module-level default
assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:09:22 -07:00
Evan
d4912c5ddd test(email): explicitly opt out of SMTP_SSL_SERVER_AUTH in test_send_mime_ssl
The new default for SMTP_SSL_SERVER_AUTH is True. test_send_mime_ssl
tests the no-server-auth code path and must explicitly set the flag to
False to avoid asserting context=None when the default now produces an
SSL context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:07:06 -07:00
Claude Code
5b7d8678c4 chore(config): default SMTP_SSL_SERVER_AUTH to True
Change the shipped default for SMTP_SSL_SERVER_AUTH from False to True so
STARTTLS/SSL connections to the SMTP server validate the server's TLS
certificate against the system CA store out of the box.

The setting remains overridable: operators using a self-signed or otherwise
untrusted certificate can restore the previous behavior by setting
SMTP_SSL_SERVER_AUTH = False in superset_config.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:07:06 -07:00
Joe Li
1bfdb19e88 test(dashboard): RTL coverage for native filter modal and sidebar (#40778)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 16:26:04 -07:00
Elizabeth Thompson
c0e78f39d7 fix: replace deprecated appbuilder.app with current_app (#40876)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:01:43 -07:00
10 changed files with 366 additions and 21 deletions

View File

@@ -58,6 +58,18 @@ GLOBAL_ASYNC_QUERIES_JWT_SECRET = "<output of: openssl rand -base64 42>"
The check is only active when the relevant feature is enabled, so deployments that do not use global async queries (or embedding) are not affected.
### 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.

View File

@@ -902,3 +902,61 @@ test('Clear All on a required filter disables Apply via validateStatus', async (
expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled();
updateDataMaskSpy.mockRestore();
});
test('FilterBar renders the configured filter name in the bar', async () => {
const filterId = 'NATIVE_FILTER-name-render';
const filter = createFilter({
id: filterId,
name: 'Region',
filterType: 'filter_select',
targets: [{ datasetId: 7, column: { name: 'region' } }],
chartsInScope: [18],
});
const state = {
...stateWithoutNativeFilters,
dashboardInfo: {
id: 1,
dash_edit_perm: true,
filterBarOrientation: FilterBarOrientation.Vertical,
metadata: {
native_filter_configuration: [filter],
chart_configuration: {},
},
},
dashboardState: {
...stateWithoutNativeFilters.dashboardState,
activeTabs: ['ROOT_ID'],
},
dataMask: { [filterId]: createDataMask(filterId, undefined, {}) },
nativeFilters: {
filters: { [filterId]: filter },
filtersState: {},
},
};
const props = createOpenedBarProps();
renderFilterBar(props, state);
await act(async () => {
jest.advanceTimersByTime(300);
});
expect(await screen.findByText('Region')).toBeInTheDocument();
});
test('Clicking the gear "Add or edit filters and controls" item opens the FiltersConfigModal', async () => {
const props = createOpenedBarProps();
renderFilterBar(props, stateWithoutNativeFilters);
await act(async () => {
jest.advanceTimersByTime(100);
});
const gear = await screen.findByTestId('filterbar-orientation-icon');
userEvent.click(gear);
const addEditItem = await screen.findByText(
'Add or edit filters and controls',
);
userEvent.click(addEditItem);
expect(await screen.findByTestId('filter-modal')).toBeInTheDocument();
});

View File

@@ -844,9 +844,7 @@ test('enables save button and includes updated title when editing an existing di
jest.useRealTimers();
await waitFor(() =>
expect(
screen.getByRole('button', { name: SAVE_REGEX }),
).not.toBeDisabled(),
expect(screen.getByRole('button', { name: SAVE_REGEX })).not.toBeDisabled(),
);
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
@@ -910,9 +908,7 @@ test('enables save button and includes updated title when editing an existing ch
jest.useRealTimers();
await waitFor(() =>
expect(
screen.getByRole('button', { name: SAVE_REGEX }),
).not.toBeDisabled(),
expect(screen.getByRole('button', { name: SAVE_REGEX })).not.toBeDisabled(),
);
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
@@ -960,3 +956,144 @@ test('empty state disappears when a filter is added via dropdown', async () => {
});
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
});
test('restores a deleted filter via the "Restore filter" button', async () => {
const nativeFilterConfig = [
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
];
const state = {
...defaultState(),
dashboardInfo: {
metadata: { native_filter_configuration: nativeFilterConfig },
},
dashboardLayout,
};
defaultRender(state, { ...props, createNewOnOpen: false });
const filterContainer = screen.getByTestId('filter-title-container');
const firstTab = within(filterContainer).getAllByRole('tab')[0];
fireEvent.click(within(firstTab).getByRole('button', { name: /delete/i }));
expect(
await screen.findByText(/you have removed this filter/i),
).toBeInTheDocument();
const restoreButton = screen.getByTestId('restore-filter-button');
await userEvent.click(restoreButton);
await waitFor(() => {
expect(
screen.queryByText(/you have removed this filter/i),
).not.toBeInTheDocument();
});
expect(screen.getByRole('textbox', { name: FILTER_NAME_REGEX })).toHaveValue(
'state',
);
}, 30000);
test('undoes a filter deletion via the sidebar "Undo?" link', async () => {
const nativeFilterConfig = [
buildNativeFilter('NATIVE_FILTER-1', 'state', []),
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
];
const state = {
...defaultState(),
dashboardInfo: {
metadata: { native_filter_configuration: nativeFilterConfig },
},
dashboardLayout,
};
defaultRender(state, { ...props, createNewOnOpen: false });
const filterContainer = screen.getByTestId('filter-title-container');
const firstTab = within(filterContainer).getAllByRole('tab')[0];
fireEvent.click(within(firstTab).getByRole('button', { name: /delete/i }));
const undoButton = await screen.findByTestId('undo-button');
expect(undoButton).toHaveTextContent(/undo\?/i);
await userEvent.click(undoButton);
await waitFor(() => {
expect(
screen.queryByText(/you have removed this filter/i),
).not.toBeInTheDocument();
});
expect(screen.getByRole('textbox', { name: FILTER_NAME_REGEX })).toHaveValue(
'state',
);
}, 30000);
test('shows info tooltips beside value-filter options and reveals tooltip text on hover', async () => {
defaultRender();
// Upstream Cypress checked six tooltips on the value filter (nativeFilterTooltips
// 0..5); asserting the count keeps this test honest if tooltips get added or
// removed alongside a regression to the option list.
const tooltipIcons = screen.getAllByLabelText(/show info tooltip/i);
expect(tooltipIcons.length).toBeGreaterThanOrEqual(6);
await userEvent.hover(tooltipIcons[0]);
// role='tooltip' trips an nwsapi bug on antd's internal :only-child selectors;
// query the portal node by class and require non-empty text content so an empty
// tooltip render does not pass.
await waitFor(() => {
const tooltip = document.querySelector('.ant-tooltip-inner');
expect(tooltip).toBeInTheDocument();
expect(tooltip?.textContent?.trim()).toBeTruthy();
});
}, 30000);
test('numerical range filter — Range Type selector lets the user pick a display mode', async () => {
defaultRender();
await userEvent.click(screen.getByText(VALUE_REGEX));
await userEvent.click(await screen.findByText(NUMERICAL_RANGE_REGEX));
const rangeTypeCombobox = await screen.findByRole('combobox', {
name: /range type/i,
});
// Default render is "Slider and range input"; asserting Slider is absent first
// ensures the post-click assertion proves a state change rather than passing on
// the default selection.
expect(
document.querySelector('.ant-select-selection-item[title="Slider"]'),
).not.toBeInTheDocument();
await userEvent.click(rangeTypeCombobox);
const sliderOption = await screen.findByRole('option', {
name: /^slider$/i,
});
await userEvent.click(sliderOption);
// antd Select renders the active selection as a span whose title attribute is
// the picked option's label.
await waitFor(() => {
expect(
document.querySelector('.ant-select-selection-item[title="Slider"]'),
).toBeInTheDocument();
});
}, 30000);
test('toggles "Filter has default value" to show and hide the Default Value control', async () => {
defaultRender();
const defaultValueCheckbox = getCheckbox(DEFAULT_VALUE_REGEX);
expect(defaultValueCheckbox).not.toBeChecked();
expect(screen.queryByText(/^default value$/i)).not.toBeInTheDocument();
await userEvent.click(defaultValueCheckbox);
expect(defaultValueCheckbox).toBeChecked();
expect(await screen.findByText(/^default value$/i)).toBeInTheDocument();
await userEvent.click(defaultValueCheckbox);
expect(defaultValueCheckbox).not.toBeChecked();
await waitFor(() => {
expect(screen.queryByText(/^default value$/i)).not.toBeInTheDocument();
});
});

View File

@@ -84,6 +84,14 @@ test('the form validates required fields', async () => {
expect(onSave).toHaveBeenCalledTimes(0);
});
async function openDropdownAndAddFilter(
getByTestId: (id: string) => HTMLElement,
findByRole: (role: string, opts: { name: RegExp }) => Promise<HTMLElement>,
) {
fireEvent.mouseEnter(getByTestId('new-item-dropdown-button'));
fireEvent.click(await findByRole('menuitem', { name: /add filter/i }));
}
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('createNewOnOpen', () => {
test('does not show alert when there is no unsaved filters', async () => {
@@ -99,15 +107,23 @@ describe('createNewOnOpen', () => {
onCancel,
createNewOnOpen: false,
});
const dropdownButton = getByTestId('new-item-dropdown-button');
fireEvent.mouseEnter(dropdownButton);
const addFilterMenuItem = await findByRole('menuitem', {
name: /add filter/i,
});
fireEvent.click(addFilterMenuItem);
await openDropdownAndAddFilter(getByTestId, findByRole);
fireEvent.click(getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(0);
expect(getByRole('alert')).toBeInTheDocument();
expect(getByRole('alert')).toHaveTextContent('There are unsaved changes.');
});
test('confirm-cancel button proceeds with cancel after the unsaved alert', async () => {
const onCancel = jest.fn();
const { getByRole, getByTestId, findByRole } = setup({
onCancel,
createNewOnOpen: false,
});
await openDropdownAndAddFilter(getByTestId, findByRole);
fireEvent.click(getByRole('button', { name: 'Cancel' }));
expect(getByRole('alert')).toBeInTheDocument();
fireEvent.click(getByTestId('native-filter-modal-confirm-cancel-button'));
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1681,8 +1681,13 @@ 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
# 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

View File

@@ -299,7 +299,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
#
# Setup regular views
#
app_root = appbuilder.app.config["APPLICATION_ROOT"]
app_root = current_app.config["APPLICATION_ROOT"]
if app_root.endswith("/"):
app_root = app_root.rstrip("/")
@@ -351,7 +351,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category="Security",
category_label=_("Security"),
menu_cond=lambda: bool(
appbuilder.app.config.get("SUPERSET_SECURITY_VIEW_MENU", True)
current_app.config.get("SUPERSET_SECURITY_VIEW_MENU", True)
),
)
@@ -361,7 +361,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
label=_("User Registrations"),
category="Security",
category_label=_("Security"),
menu_cond=lambda: bool(appbuilder.app.config["AUTH_USER_REGISTRATION"]),
menu_cond=lambda: bool(current_app.config["AUTH_USER_REGISTRATION"]),
)
appbuilder.add_view(
@@ -371,7 +371,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category="Security",
category_label=_("Security"),
menu_cond=lambda: bool(
appbuilder.app.config.get("SUPERSET_SECURITY_VIEW_MENU", True)
current_app.config.get("SUPERSET_SECURITY_VIEW_MENU", True)
),
)
@@ -382,7 +382,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category="Security",
category_label=_("Security"),
menu_cond=lambda: bool(
appbuilder.app.config.get("SUPERSET_SECURITY_VIEW_MENU", True)
current_app.config.get("SUPERSET_SECURITY_VIEW_MENU", True)
),
)

View File

@@ -17,7 +17,7 @@
{% import 'appbuilder/general/lib.html' as lib %}
{% from 'superset/partials/asset_bundle.html' import css_bundle, js_bundle with context %}
{% import "superset/macros.html" as macros %}
{% set favicons = appbuilder.app.config['FAVICONS'] %}
{% set favicons = config['FAVICONS'] %}
<head>
<title>

View File

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

View File

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

View File

@@ -96,11 +96,11 @@ def test_spa_template_standalone_body_has_min_height():
)
appbuilder = Mock()
appbuilder.app.config = {"FAVICONS": []}
def render(standalone_mode: bool) -> str:
return env.get_template("spa.html").render(
appbuilder=appbuilder,
config={"FAVICONS": []},
assets_prefix="",
bootstrap_data="{}",
entry="spa",