mirror of
https://github.com/apache/superset.git
synced 2026-05-24 09:15:19 +00:00
468 lines
16 KiB
Python
468 lines
16 KiB
Python
# 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.
|
|
"""Unit tests for UpdateReportScheduleCommand.validate()."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
from pytest_mock import MockerFixture
|
|
|
|
from superset.commands.report.exceptions import (
|
|
ReportScheduleForbiddenError,
|
|
ReportScheduleInvalidError,
|
|
)
|
|
from superset.commands.report.update import UpdateReportScheduleCommand
|
|
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
|
from superset.exceptions import SupersetSecurityException
|
|
from superset.reports.models import (
|
|
ReportCreationMethod,
|
|
ReportScheduleType,
|
|
ReportState,
|
|
)
|
|
|
|
|
|
def _make_model(
|
|
mocker: MockerFixture,
|
|
*,
|
|
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"
|
|
model.owners = []
|
|
return model
|
|
|
|
|
|
def _setup_mocks(mocker: MockerFixture, model: Mock) -> None:
|
|
mocker.patch(
|
|
"superset.commands.report.update.ReportScheduleDAO.find_by_id",
|
|
return_value=model,
|
|
)
|
|
mocker.patch(
|
|
"superset.commands.report.update.ReportScheduleDAO.validate_update_uniqueness",
|
|
return_value=True,
|
|
)
|
|
mocker.patch(
|
|
"superset.commands.report.update.security_manager.raise_for_ownership",
|
|
)
|
|
mocker.patch(
|
|
"superset.commands.report.update.DatabaseDAO.find_by_id",
|
|
return_value=mocker.Mock(),
|
|
)
|
|
mocker.patch.object(
|
|
UpdateReportScheduleCommand,
|
|
"validate_chart_dashboard",
|
|
)
|
|
mocker.patch.object(
|
|
UpdateReportScheduleCommand,
|
|
"validate_report_frequency",
|
|
)
|
|
mocker.patch.object(
|
|
UpdateReportScheduleCommand,
|
|
"compute_owners",
|
|
return_value=[],
|
|
)
|
|
|
|
|
|
def _get_validation_messages(
|
|
exc_info: pytest.ExceptionInfo[ReportScheduleInvalidError],
|
|
) -> dict[str, str]:
|
|
"""Extract field→first message string from ReportScheduleInvalidError."""
|
|
raw = exc_info.value.normalized_messages()
|
|
result = {}
|
|
for field, msgs in raw.items():
|
|
if isinstance(msgs, list):
|
|
result[field] = str(msgs[0])
|
|
else:
|
|
result[field] = str(msgs)
|
|
return result
|
|
|
|
|
|
# --- Report type: database must NOT be set ---
|
|
|
|
|
|
def test_report_with_database_in_payload_rejected(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={"database": 5})
|
|
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
|
cmd.validate()
|
|
messages = _get_validation_messages(exc_info)
|
|
assert "database" in messages
|
|
assert "not allowed" in messages["database"].lower()
|
|
|
|
|
|
def test_report_with_database_none_in_payload_accepted(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={"database": None})
|
|
cmd.validate() # should not raise
|
|
|
|
|
|
def test_report_no_database_in_payload_model_has_db_rejected(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=5)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={})
|
|
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
|
cmd.validate()
|
|
messages = _get_validation_messages(exc_info)
|
|
assert "database" in messages
|
|
assert "not allowed" in messages["database"].lower()
|
|
|
|
|
|
def test_report_no_database_anywhere_accepted(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={})
|
|
cmd.validate() # should not raise
|
|
|
|
|
|
# --- Alert type: database MUST be set ---
|
|
|
|
|
|
def test_alert_with_database_in_payload_accepted(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.ALERT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={"database": 5})
|
|
cmd.validate() # should not raise
|
|
|
|
|
|
def test_alert_with_database_none_in_payload_rejected(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.ALERT, database_id=5)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={"database": None})
|
|
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
|
cmd.validate()
|
|
messages = _get_validation_messages(exc_info)
|
|
assert "database" in messages
|
|
assert "required" in messages["database"].lower()
|
|
|
|
|
|
def test_alert_no_database_in_payload_model_has_db_accepted(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.ALERT, database_id=5)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={})
|
|
cmd.validate() # should not raise
|
|
|
|
|
|
def test_alert_no_database_anywhere_rejected(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.ALERT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={})
|
|
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
|
cmd.validate()
|
|
messages = _get_validation_messages(exc_info)
|
|
assert "database" in messages
|
|
assert "required" in messages["database"].lower()
|
|
|
|
|
|
# --- Type transitions ---
|
|
|
|
|
|
def test_alert_to_report_without_clearing_db_rejected(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.ALERT, database_id=5)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(
|
|
model_id=1, data={"type": ReportScheduleType.REPORT}
|
|
)
|
|
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
|
cmd.validate()
|
|
messages = _get_validation_messages(exc_info)
|
|
assert "database" in messages
|
|
assert "not allowed" in messages["database"].lower()
|
|
|
|
|
|
def test_alert_to_report_with_db_cleared_accepted(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.ALERT, database_id=5)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(
|
|
model_id=1,
|
|
data={"type": ReportScheduleType.REPORT, "database": None},
|
|
)
|
|
cmd.validate() # should not raise
|
|
|
|
|
|
def test_report_to_alert_without_db_rejected(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(
|
|
model_id=1, data={"type": ReportScheduleType.ALERT}
|
|
)
|
|
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
|
cmd.validate()
|
|
messages = _get_validation_messages(exc_info)
|
|
assert "database" in messages
|
|
assert "required" in messages["database"].lower()
|
|
|
|
|
|
def test_report_with_nonexistent_database_returns_not_allowed(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""Report + nonexistent DB must return 'not allowed', not 'does not exist'."""
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
mocker.patch(
|
|
"superset.commands.report.update.DatabaseDAO.find_by_id",
|
|
return_value=None,
|
|
)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={"database": 99999})
|
|
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
|
cmd.validate()
|
|
messages = _get_validation_messages(exc_info)
|
|
assert "database" in messages
|
|
assert "not allowed" in messages["database"].lower()
|
|
assert "does not exist" not in messages["database"].lower()
|
|
|
|
|
|
def test_report_to_alert_with_db_accepted(mocker: MockerFixture) -> None:
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(
|
|
model_id=1,
|
|
data={"type": ReportScheduleType.ALERT, "database": 5},
|
|
)
|
|
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 ---
|
|
|
|
|
|
def test_deactivation_resets_working_state_to_noop(mocker: MockerFixture) -> None:
|
|
"""Deactivating a report in WORKING state should reset last_state to NOOP."""
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
|
model.last_state = ReportState.WORKING
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={"active": False})
|
|
cmd.validate()
|
|
assert cmd._properties["last_state"] == ReportState.NOOP
|
|
|
|
|
|
def test_deactivation_from_non_working_does_not_reset(mocker: MockerFixture) -> None:
|
|
"""Deactivating a report NOT in WORKING state should not touch last_state."""
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
|
model.last_state = ReportState.SUCCESS
|
|
_setup_mocks(mocker, model)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={"active": False})
|
|
cmd.validate()
|
|
assert "last_state" not in cmd._properties
|
|
|
|
|
|
# --- Ownership check ---
|
|
|
|
|
|
def test_ownership_check_raises_forbidden(mocker: MockerFixture) -> None:
|
|
"""Non-owner should get ReportScheduleForbiddenError."""
|
|
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
mocker.patch(
|
|
"superset.commands.report.update.security_manager.raise_for_ownership",
|
|
side_effect=SupersetSecurityException(
|
|
SupersetError(
|
|
message="Forbidden",
|
|
error_type=SupersetErrorType.GENERIC_BACKEND_ERROR,
|
|
level=ErrorLevel.ERROR,
|
|
)
|
|
),
|
|
)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={})
|
|
with pytest.raises(ReportScheduleForbiddenError):
|
|
cmd.validate()
|
|
|
|
|
|
# --- Database not found for alert ---
|
|
|
|
|
|
def test_alert_with_nonexistent_database_rejected(mocker: MockerFixture) -> None:
|
|
"""Alert with a database ID that doesn't exist should fail validation."""
|
|
model = _make_model(mocker, model_type=ReportScheduleType.ALERT, database_id=None)
|
|
_setup_mocks(mocker, model)
|
|
mocker.patch(
|
|
"superset.commands.report.update.DatabaseDAO.find_by_id",
|
|
return_value=None,
|
|
)
|
|
|
|
cmd = UpdateReportScheduleCommand(model_id=1, data={"database": 99999})
|
|
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
|
cmd.validate()
|
|
messages = _get_validation_messages(exc_info)
|
|
assert "database" in messages
|
|
assert "does not exist" in messages["database"].lower()
|