mirror of
https://github.com/apache/superset.git
synced 2026-05-29 20:29:34 +00:00
fix(reports): enforce server-side recipient on chart/dashboard report subscriptions (#38847)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,11 +14,11 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Unit tests for UpdateReportScheduleCommand.validate() database invariants."""
|
||||
"""Unit tests for UpdateReportScheduleCommand.validate()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
@@ -30,7 +30,11 @@ from superset.commands.report.exceptions import (
|
||||
from superset.commands.report.update import UpdateReportScheduleCommand
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.reports.models import ReportScheduleType, ReportState
|
||||
from superset.reports.models import (
|
||||
ReportCreationMethod,
|
||||
ReportScheduleType,
|
||||
ReportState,
|
||||
)
|
||||
|
||||
|
||||
def _make_model(
|
||||
@@ -38,10 +42,12 @@ def _make_model(
|
||||
*,
|
||||
model_type: ReportScheduleType | str,
|
||||
database_id: int | None,
|
||||
creation_method: ReportCreationMethod = ReportCreationMethod.ALERTS_REPORTS,
|
||||
) -> Mock:
|
||||
model = mocker.Mock()
|
||||
model.type = model_type
|
||||
model.database_id = database_id
|
||||
model.creation_method = creation_method
|
||||
model.name = "test_schedule"
|
||||
model.crontab = "0 9 * * *"
|
||||
model.last_state = "noop"
|
||||
@@ -257,6 +263,142 @@ def test_report_to_alert_with_db_accepted(mocker: MockerFixture) -> None:
|
||||
cmd.validate() # should not raise
|
||||
|
||||
|
||||
# --- Recipient enforcement for chart/dashboard reports ---
|
||||
|
||||
_PATCH_GET_USER_EMAIL = "superset.commands.report.update.get_user_email"
|
||||
|
||||
|
||||
def test_chart_report_update_recipient_overridden_with_owner_email(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Updating recipients on a chart report always locks them to the owner's email."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.CHARTS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
data = {
|
||||
"recipients": [
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {"target": "other@example.com"},
|
||||
}
|
||||
]
|
||||
}
|
||||
cmd = UpdateReportScheduleCommand(model_id=1, data=data)
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
|
||||
cmd.validate()
|
||||
|
||||
recipients = cmd._properties["recipients"]
|
||||
assert len(recipients) == 1
|
||||
assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com"
|
||||
|
||||
|
||||
def test_dashboard_report_update_recipient_overridden_with_owner_email(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Updating recipients on a dashboard report locks them to the owner's email."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.DASHBOARDS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
data = {
|
||||
"recipients": [
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {"target": "other@example.com"},
|
||||
}
|
||||
]
|
||||
}
|
||||
cmd = UpdateReportScheduleCommand(model_id=1, data=data)
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
|
||||
cmd.validate()
|
||||
|
||||
recipients = cmd._properties["recipients"]
|
||||
assert len(recipients) == 1
|
||||
assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com"
|
||||
|
||||
|
||||
def test_alerts_reports_update_recipient_not_overridden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Recipients on admin-created alerts/reports are not modified on update."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.ALERTS_REPORTS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
original_recipient = {
|
||||
"type": "Email",
|
||||
"recipient_config_json": {"target": "team@example.com"},
|
||||
}
|
||||
cmd = UpdateReportScheduleCommand(
|
||||
model_id=1, data={"recipients": [original_recipient]}
|
||||
)
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
|
||||
cmd.validate()
|
||||
|
||||
assert (
|
||||
cmd._properties["recipients"][0]["recipient_config_json"]["target"]
|
||||
== "team@example.com"
|
||||
)
|
||||
|
||||
|
||||
def test_chart_report_update_no_recipients_in_payload_unchanged(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""If recipients are not in the update payload, nothing is changed."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.CHARTS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
cmd = UpdateReportScheduleCommand(model_id=1, data={"name": "new name"})
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
|
||||
cmd.validate()
|
||||
|
||||
assert "recipients" not in cmd._properties
|
||||
|
||||
|
||||
def test_chart_report_update_no_user_email_raises(mocker: MockerFixture) -> None:
|
||||
"""Update fails with a validation error when the user has no email address."""
|
||||
model = _make_model(
|
||||
mocker,
|
||||
model_type=ReportScheduleType.REPORT,
|
||||
database_id=None,
|
||||
creation_method=ReportCreationMethod.CHARTS,
|
||||
)
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
cmd = UpdateReportScheduleCommand(
|
||||
model_id=1,
|
||||
data={
|
||||
"recipients": [
|
||||
{"type": "Email", "recipient_config_json": {"target": "x@y.com"}}
|
||||
]
|
||||
},
|
||||
)
|
||||
with patch(_PATCH_GET_USER_EMAIL, return_value=None):
|
||||
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
||||
cmd.validate()
|
||||
|
||||
messages = _get_validation_messages(exc_info)
|
||||
assert "recipients" in messages
|
||||
|
||||
|
||||
# --- Deactivation state reset ---
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ import pytest
|
||||
from marshmallow import ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.reports.schemas import ReportSchedulePostSchema, ReportSchedulePutSchema
|
||||
from superset.reports.schemas import (
|
||||
ReportRecipientSchema,
|
||||
ReportSchedulePostSchema,
|
||||
ReportSchedulePutSchema,
|
||||
ReportScheduleSubscribeSchema,
|
||||
)
|
||||
|
||||
|
||||
def test_report_post_schema_custom_width_validation(mocker: MockerFixture) -> None:
|
||||
@@ -77,6 +82,157 @@ def test_report_post_schema_custom_width_validation(mocker: MockerFixture) -> No
|
||||
}
|
||||
|
||||
|
||||
def test_report_recipient_schema_email_valid() -> None:
|
||||
"""Valid email target is accepted by the recipient schema."""
|
||||
schema = ReportRecipientSchema()
|
||||
result = schema.load(
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {"target": "user@example.com"},
|
||||
}
|
||||
)
|
||||
assert result["recipient_config_json"]["target"] == "user@example.com"
|
||||
|
||||
|
||||
def test_report_recipient_schema_email_invalid_target() -> None:
|
||||
"""Invalid email address in target field raises a validation error."""
|
||||
schema = ReportRecipientSchema()
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
schema.load(
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {"target": "not-an-email"},
|
||||
}
|
||||
)
|
||||
assert "target" in excinfo.value.messages
|
||||
|
||||
|
||||
def test_report_recipient_schema_email_invalid_cc() -> None:
|
||||
"""Invalid address in ccTarget field raises a validation error."""
|
||||
schema = ReportRecipientSchema()
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
schema.load(
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {
|
||||
"target": "user@example.com",
|
||||
"ccTarget": "bad-email",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert "ccTarget" in excinfo.value.messages
|
||||
|
||||
|
||||
def test_report_recipient_schema_email_invalid_bcc() -> None:
|
||||
"""Invalid address in bccTarget field raises a validation error."""
|
||||
schema = ReportRecipientSchema()
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
schema.load(
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {
|
||||
"target": "user@example.com",
|
||||
"bccTarget": "not-valid",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert "bccTarget" in excinfo.value.messages
|
||||
|
||||
|
||||
def test_report_recipient_schema_email_empty_bcc_allowed() -> None:
|
||||
"""Empty string in bccTarget is accepted (optional field)."""
|
||||
schema = ReportRecipientSchema()
|
||||
result = schema.load(
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {
|
||||
"target": "user@example.com",
|
||||
"bccTarget": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert result["recipient_config_json"]["target"] == "user@example.com"
|
||||
|
||||
|
||||
def test_report_recipient_schema_email_empty_cc_allowed() -> None:
|
||||
"""Empty string in ccTarget is accepted (optional field)."""
|
||||
schema = ReportRecipientSchema()
|
||||
result = schema.load(
|
||||
{
|
||||
"type": "Email",
|
||||
"recipient_config_json": {
|
||||
"target": "user@example.com",
|
||||
"ccTarget": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert result["recipient_config_json"]["target"] == "user@example.com"
|
||||
|
||||
|
||||
def test_report_recipient_schema_slack_skips_email_validation() -> None:
|
||||
"""Slack recipients are not validated as email addresses."""
|
||||
schema = ReportRecipientSchema()
|
||||
result = schema.load(
|
||||
{
|
||||
"type": "Slack",
|
||||
"recipient_config_json": {"target": "#general"},
|
||||
}
|
||||
)
|
||||
assert result["recipient_config_json"]["target"] == "#general"
|
||||
|
||||
|
||||
def test_subscribe_schema_ignores_excluded_fields(mocker: MockerFixture) -> None:
|
||||
"""Excluded fields sent by the client are silently dropped, not rejected."""
|
||||
mocker.patch(
|
||||
"flask.current_app.config",
|
||||
{
|
||||
"ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH": 100,
|
||||
"ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH": 2000,
|
||||
},
|
||||
)
|
||||
schema = ReportScheduleSubscribeSchema()
|
||||
result = schema.load(
|
||||
{
|
||||
"type": "Report",
|
||||
"name": "My subscription",
|
||||
"crontab": "0 9 * * *",
|
||||
"timezone": "UTC",
|
||||
"chart": 1,
|
||||
# These are excluded server-side — should be silently dropped
|
||||
"recipients": [
|
||||
{"type": "Email", "recipient_config_json": {"target": "x@y.com"}}
|
||||
],
|
||||
"creation_method": "alerts_reports",
|
||||
}
|
||||
)
|
||||
assert "recipients" not in result
|
||||
assert "creation_method" not in result
|
||||
assert "owners" not in result
|
||||
|
||||
|
||||
def test_subscribe_schema_rejects_alert_type(mocker: MockerFixture) -> None:
|
||||
"""Subscribe endpoint must not allow Alert type — prevents privilege escalation."""
|
||||
mocker.patch(
|
||||
"flask.current_app.config",
|
||||
{
|
||||
"ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH": 100,
|
||||
"ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH": 2000,
|
||||
},
|
||||
)
|
||||
schema = ReportScheduleSubscribeSchema()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load(
|
||||
{
|
||||
"type": "Alert",
|
||||
"name": "My alert",
|
||||
"crontab": "0 9 * * *",
|
||||
"timezone": "UTC",
|
||||
"chart": 1,
|
||||
}
|
||||
)
|
||||
assert "type" in exc_info.value.messages
|
||||
|
||||
|
||||
MINIMAL_POST_PAYLOAD = {
|
||||
"type": "Report",
|
||||
"name": "A report",
|
||||
|
||||
Reference in New Issue
Block a user