Files
superset2/tests/unit_tests/commands/report/base_test.py
T

413 lines
13 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.
from __future__ import annotations
import logging
from datetime import timedelta
from functools import wraps
from typing import Any, Callable
from unittest.mock import patch
import pytest
from pytest_mock import MockerFixture
from superset.commands.report.base import BaseReportScheduleCommand
from superset.commands.report.exceptions import (
ReportScheduleCrontabNotValidError,
ReportScheduleFrequencyNotAllowed,
)
from superset.reports.models import ReportScheduleType
REPORT_TYPES = {
ReportScheduleType.ALERT,
ReportScheduleType.REPORT,
}
TEST_SCHEDULES_EVERY_MINUTE = {
"* * * * *",
"1-5 * * * *",
"10-20 * * * *",
"0,45,10-20 * * * *",
"23,45,50,51 * * * *",
"10,20,30,40-45 * * * *",
}
TEST_SCHEDULES_SINGLE_MINUTES = {
"1,5,8,10,12 * * * *",
"10 1 * * *",
"27,2 1-5 * * *",
}
TEST_SCHEDULES = TEST_SCHEDULES_EVERY_MINUTE.union(TEST_SCHEDULES_SINGLE_MINUTES)
def dynamic_alert_minimum_interval(**kwargs) -> int:
return int(timedelta(minutes=10).total_seconds())
def dynamic_report_minimum_interval(**kwargs) -> int:
return int(timedelta(minutes=5).total_seconds())
def app_custom_config(
alert_minimum_interval: int | str | Callable[[], int] = 0,
report_minimum_interval: int | str | Callable[[], int] = 0,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
Decorator to mock the current_app.config values dynamically for each test.
:param alert_minimum_interval: Minimum interval. Defaults to None.
:param report_minimum_interval: Minimum interval. Defaults to None.
:returns: A decorator that wraps a function.
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
config_overrides = {
"ALERT_MINIMUM_INTERVAL": alert_minimum_interval,
"REPORT_MINIMUM_INTERVAL": report_minimum_interval,
}
with patch("flask.current_app.config", config_overrides):
return func(*args, **kwargs)
return wrapper
return decorator
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@pytest.mark.parametrize("schedule", TEST_SCHEDULES)
@app_custom_config()
def test_validate_report_frequency(report_type: str, schedule: str) -> None:
"""
Test the ``validate_report_frequency`` method when there's
no minimum frequency configured.
"""
BaseReportScheduleCommand().validate_report_frequency(
schedule,
report_type,
)
@app_custom_config(
alert_minimum_interval=int(timedelta(minutes=4).total_seconds()),
report_minimum_interval=int(timedelta(minutes=5).total_seconds()),
)
def test_validate_report_frequency_minimum_set() -> None:
"""
Test the ``validate_report_frequency`` method when there's
minimum frequencies configured.
"""
BaseReportScheduleCommand().validate_report_frequency(
"1,5 * * * *",
ReportScheduleType.ALERT,
)
BaseReportScheduleCommand().validate_report_frequency(
"6,11 * * * *",
ReportScheduleType.REPORT,
)
@app_custom_config(
alert_minimum_interval=int(timedelta(minutes=2).total_seconds()),
report_minimum_interval=int(timedelta(minutes=5).total_seconds()),
)
def test_validate_report_frequency_invalid_schedule() -> None:
"""
Test the ``validate_report_frequency`` method when the configured
schedule exceeds the limit.
"""
with pytest.raises(ReportScheduleFrequencyNotAllowed):
BaseReportScheduleCommand().validate_report_frequency(
"1,2 * * * *",
ReportScheduleType.ALERT,
)
with pytest.raises(ReportScheduleFrequencyNotAllowed):
BaseReportScheduleCommand().validate_report_frequency(
"1,5 * * * *",
ReportScheduleType.REPORT,
)
@pytest.mark.parametrize("schedule", TEST_SCHEDULES)
@app_custom_config(
alert_minimum_interval=int(timedelta(minutes=10).total_seconds()),
)
def test_validate_report_frequency_alert_only(schedule: str) -> None:
"""
Test the ``validate_report_frequency`` method when there's
only a configuration for alerts and user is creating report.
"""
BaseReportScheduleCommand().validate_report_frequency(
schedule,
ReportScheduleType.REPORT,
)
@pytest.mark.parametrize("schedule", TEST_SCHEDULES)
@app_custom_config(
report_minimum_interval=int(timedelta(minutes=10).total_seconds()),
)
def test_validate_report_frequency_report_only(schedule: str) -> None:
"""
Test the ``validate_report_frequency`` method when there's
only a configuration for reports and user is creating alert.
"""
BaseReportScheduleCommand().validate_report_frequency(
schedule,
ReportScheduleType.ALERT,
)
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@app_custom_config(
alert_minimum_interval=int(timedelta(minutes=5).total_seconds()),
report_minimum_interval=int(timedelta(minutes=5).total_seconds()),
)
def test_validate_report_frequency_never_matching_crontab(report_type: str) -> None:
"""
Test the ``validate_report_frequency`` method with a crontab that is
syntactically valid but never matches a real calendar date (Feb 30th).
Such schedules pass ``croniter.is_valid()`` (purely syntactic) and thus
marshmallow schema validation, but raise ``CroniterBadDateError`` when
iterated. This should surface as a ``ValidationError`` rather than
propagating the raw croniter exception.
"""
with pytest.raises(ReportScheduleCrontabNotValidError):
BaseReportScheduleCommand().validate_report_frequency(
"0 0 30 2 *",
report_type,
)
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@pytest.mark.parametrize("schedule", TEST_SCHEDULES)
@app_custom_config(
alert_minimum_interval=int(timedelta(minutes=1).total_seconds()),
report_minimum_interval=int(timedelta(minutes=1).total_seconds()),
)
def test_validate_report_frequency_accepts_every_minute_with_one(
report_type: str, schedule: str
) -> None:
"""
Test the ``validate_report_frequency`` method when configuration
is set to `1`. Validates the usage of `-` and `*` in the cron.
"""
BaseReportScheduleCommand().validate_report_frequency(
schedule,
report_type,
)
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@pytest.mark.parametrize("schedule", TEST_SCHEDULES_SINGLE_MINUTES)
@app_custom_config(
alert_minimum_interval=int(timedelta(minutes=2).total_seconds()),
report_minimum_interval=int(timedelta(minutes=2).total_seconds()),
)
def test_validate_report_frequency_accepts_every_minute_with_two(
report_type: str,
schedule: str,
) -> None:
"""
Test the ``validate_report_frequency`` method when configuration
is set to `2`.
"""
BaseReportScheduleCommand().validate_report_frequency(
schedule,
report_type,
)
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@pytest.mark.parametrize("schedule", TEST_SCHEDULES_EVERY_MINUTE)
@app_custom_config(
alert_minimum_interval=int(timedelta(minutes=2).total_seconds()),
report_minimum_interval=int(timedelta(minutes=2).total_seconds()),
)
def test_validate_report_frequency_accepts_every_minute_with_two_raises(
report_type: str,
schedule: str,
) -> None:
"""
Test the ``validate_report_frequency`` method when configuration
is set to `2`. Validates the usage of `-` and `*` in the cron.
"""
# Should fail for schedules with `-` and `*`
with pytest.raises(ReportScheduleFrequencyNotAllowed):
BaseReportScheduleCommand().validate_report_frequency(
schedule,
report_type,
)
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@pytest.mark.parametrize("schedule", TEST_SCHEDULES)
@app_custom_config(
alert_minimum_interval="10 minutes",
report_minimum_interval="10 minutes",
)
def test_validate_report_frequency_invalid_config(
caplog: pytest.LogCaptureFixture,
report_type: str,
schedule: str,
) -> None:
"""
Test the ``validate_report_frequency`` method when the configuration
is invalid.
"""
caplog.set_level(logging.ERROR)
BaseReportScheduleCommand().validate_report_frequency(
schedule,
report_type,
)
expected_error_message = (
f"invalid value for {report_type}_MINIMUM_INTERVAL: 10 minutes"
)
assert expected_error_message.lower() in caplog.text.lower()
@app_custom_config(
alert_minimum_interval=dynamic_alert_minimum_interval,
report_minimum_interval=dynamic_report_minimum_interval,
)
def test_validate_report_frequency_using_callable() -> None:
"""
Test the ``validate_report_frequency`` method when the config
values are set to a function.
"""
# Should fail with a 9 minutes interval, and work with 10
with pytest.raises(ReportScheduleFrequencyNotAllowed):
BaseReportScheduleCommand().validate_report_frequency(
"1,10 * * * *",
ReportScheduleType.ALERT,
)
BaseReportScheduleCommand().validate_report_frequency(
"1,11 * * * *",
ReportScheduleType.ALERT,
)
# Should fail with a 4 minutes interval, and work with 5
with pytest.raises(ReportScheduleFrequencyNotAllowed):
BaseReportScheduleCommand().validate_report_frequency(
"1,5 * * * *",
ReportScheduleType.REPORT,
)
BaseReportScheduleCommand().validate_report_frequency(
"1,6 * * * *",
ReportScheduleType.REPORT,
)
def test_validate_alert_query_rejects_multi_statement_sql() -> None:
"""
Alert SQL is validated at save time; multi-statement SQL cannot be
persisted for later raw execution by the alert runner.
"""
from unittest.mock import MagicMock
from marshmallow import ValidationError
from superset.commands.report.base import BaseReportScheduleCommand
from superset.commands.report.exceptions import (
AlertQueryMultipleStatementsValidationError,
)
database = MagicMock()
database.backend = "sqlite"
database.allow_dml = False
exceptions: list[ValidationError] = []
BaseReportScheduleCommand().validate_alert_query(
database, "SELECT 1; DROP TABLE ab_user", exceptions
)
assert len(exceptions) == 1
assert isinstance(exceptions[0], AlertQueryMultipleStatementsValidationError)
def test_validate_alert_query_rejects_dml_when_not_allowed() -> None:
"""A mutating alert query is rejected unless the database allows DML."""
from unittest.mock import MagicMock
from marshmallow import ValidationError
from superset.commands.report.base import BaseReportScheduleCommand
from superset.commands.report.exceptions import (
AlertQueryDMLNotAllowedValidationError,
)
database = MagicMock()
database.backend = "sqlite"
database.allow_dml = False
exceptions: list[ValidationError] = []
BaseReportScheduleCommand().validate_alert_query(
database, "UPDATE ab_user SET active = 1", exceptions
)
assert len(exceptions) == 1
assert isinstance(exceptions[0], AlertQueryDMLNotAllowedValidationError)
def test_validate_alert_query_rejects_unauthorized_tables(
mocker: MockerFixture,
) -> None:
"""A single read-only statement referencing tables the user cannot access
is rejected via the table-level authorization check."""
from unittest.mock import MagicMock
from marshmallow import ValidationError
from superset.commands.report.base import BaseReportScheduleCommand
from superset.commands.report.exceptions import (
AlertQueryDataAccessValidationError,
)
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
mocker.patch(
"superset.commands.report.base.security_manager.raise_for_access",
side_effect=SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
message="You need access to the following tables: `secret`",
level=ErrorLevel.ERROR,
)
),
)
database = MagicMock()
database.backend = "sqlite"
database.allow_dml = False
exceptions: list[ValidationError] = []
BaseReportScheduleCommand().validate_alert_query(
database, "SELECT * FROM secret", exceptions
)
assert len(exceptions) == 1
assert isinstance(exceptions[0], AlertQueryDataAccessValidationError)