Files
superset2/tests/unit_tests/commands/test_base_restore_command.py

192 lines
7.4 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 ``BaseRestoreCommand.validate``.
Concrete entity restore commands (chart, dashboard, dataset) have their
own integration tests on the entity-rollout PRs. This module exercises
the abstract base class directly so the validation contract is pinned
at the infrastructure level — refactors to ``BaseRestoreCommand`` get
fast local feedback rather than waiting for entity-branch integration
CI.
"""
from __future__ import annotations
from datetime import datetime
from typing import ClassVar
from unittest.mock import MagicMock, patch
import pytest
from superset.commands.restore import BaseRestoreCommand
from superset.exceptions import SupersetSecurityException
from superset.models.helpers import SoftDeleteMixin
class _NotFoundError(Exception):
"""Synthetic not-found exception for tests."""
class _ForbiddenError(Exception):
"""Synthetic forbidden exception for tests."""
class _RestoreFailedError(Exception):
"""Synthetic restore-failed exception used by the transaction wrapper."""
def _make_command(
dao_find_result: object,
) -> BaseRestoreCommand[SoftDeleteMixin]:
"""Build a concrete ``BaseRestoreCommand`` subclass whose DAO is a
``MagicMock`` returning ``dao_find_result`` from ``find_by_id``.
"""
dao_mock = MagicMock()
dao_mock.find_by_id.return_value = dao_find_result
class _SyntheticRestoreCommand(BaseRestoreCommand[SoftDeleteMixin]):
dao: ClassVar[MagicMock] = dao_mock
not_found_exc: ClassVar[type[Exception]] = _NotFoundError
forbidden_exc: ClassVar[type[Exception]] = _ForbiddenError
restore_failed_exc: ClassVar[type[Exception]] = _RestoreFailedError
return _SyntheticRestoreCommand("uuid-1")
def test_validate_raises_not_found_when_model_missing(app_context: None) -> None:
"""If the DAO can't find the row, validate() raises ``not_found_exc``."""
cmd = _make_command(dao_find_result=None)
with pytest.raises(_NotFoundError, match="No row with uuid"):
cmd.validate()
def test_validate_raises_not_found_when_model_is_live(app_context: None) -> None:
"""A live row (``deleted_at is None``) has nothing to restore;
validate() raises ``not_found_exc`` with a message that points at
the "not soft-deleted" case (distinguishable from "no such row")."""
live = MagicMock()
live.deleted_at = None
cmd = _make_command(dao_find_result=live)
with pytest.raises(_NotFoundError, match="not soft-deleted"):
cmd.validate()
def test_validate_returns_model_when_editable_and_soft_deleted(
app_context: None,
) -> None:
"""A soft-deleted row editable by the caller passes the editorship check
and is returned to ``run()`` for restoration."""
soft_deleted = MagicMock()
soft_deleted.deleted_at = datetime(2026, 1, 1)
cmd = _make_command(dao_find_result=soft_deleted)
with patch("superset.commands.restore.security_manager") as mock_sec:
mock_sec.raise_for_editorship = MagicMock(return_value=None)
result = cmd.validate()
assert result is soft_deleted
mock_sec.raise_for_editorship.assert_called_once_with(soft_deleted)
def test_validate_raises_forbidden_when_editorship_check_fails(
app_context: None,
) -> None:
"""The security manager's raise_for_editorship raises
``SupersetSecurityException`` for non-editors; validate() translates
that to the command's ``forbidden_exc`` (keeping the security-layer
exception type out of caller code)."""
soft_deleted = MagicMock()
soft_deleted.deleted_at = datetime(2026, 1, 1)
cmd = _make_command(dao_find_result=soft_deleted)
def reject_editorship(_resource: object) -> None:
raise SupersetSecurityException(MagicMock())
with patch("superset.commands.restore.security_manager") as mock_sec:
mock_sec.raise_for_editorship = reject_editorship
with pytest.raises(_ForbiddenError):
cmd.validate()
def test_validate_calls_dao_with_both_bypasses(app_context: None) -> None:
"""The DAO load uses ``skip_visibility_filter=True`` (so the
soft-deleted row is visible), ``skip_base_filter=True``, and
``id_column='uuid'``. The base-filter bypass is deliberate: an
entity's RBAC ``base_filter`` may have no editorship leg (charts and
datasets filter by datasource access), and ``raise_for_access``
counts editorship as datasource access — so a lost grant must not
hide a row from the one audience that can restore it. The restore
audience (editors or admin) is enforced by ``raise_for_editorship``
instead."""
soft_deleted = MagicMock()
soft_deleted.deleted_at = datetime(2026, 1, 1)
cmd = _make_command(dao_find_result=soft_deleted)
with patch("superset.commands.restore.security_manager") as mock_sec:
mock_sec.raise_for_editorship = MagicMock(return_value=None)
cmd.validate()
cmd.dao.find_by_id.assert_called_once_with(
"uuid-1",
id_column="uuid",
skip_base_filter=True,
skip_visibility_filter=True,
)
def test_run_calls_model_restore_on_success(app_context: None) -> None:
"""The happy path: ``run()`` resolves the model via ``validate()`` and
calls ``model.restore()`` on it. The transactional wrapper applied
by the base ``run()`` commits the cleared ``deleted_at`` to the DB.
"""
soft_deleted = MagicMock()
soft_deleted.deleted_at = datetime(2026, 1, 1)
cmd = _make_command(dao_find_result=soft_deleted)
with patch("superset.commands.restore.security_manager") as mock_sec:
mock_sec.raise_for_editorship = MagicMock(return_value=None)
cmd.run()
soft_deleted.restore.assert_called_once_with()
def test_run_translates_sqlalchemy_errors_via_restore_failed_exc(
app_context: None,
) -> None:
"""The base ``run()`` wraps the operation in ``@transaction(on_error=
partial(on_error, reraise=self.restore_failed_exc))``. A SQLAlchemy
error raised below (e.g., during commit) gets caught and re-raised
as the subclass's ``restore_failed_exc``. Pinning this prevents a
refactor from accidentally dropping the transaction wrapper or
pointing it at the wrong exception type.
"""
from sqlalchemy.exc import SQLAlchemyError
soft_deleted = MagicMock()
soft_deleted.deleted_at = datetime(2026, 1, 1)
# model.restore() raises during the transactional block — simulates a
# SQLAlchemy failure inside the unit-of-work.
soft_deleted.restore.side_effect = SQLAlchemyError("simulated commit failure")
cmd = _make_command(dao_find_result=soft_deleted)
with patch("superset.commands.restore.security_manager") as mock_sec:
mock_sec.raise_for_editorship = MagicMock(return_value=None)
with pytest.raises(_RestoreFailedError):
cmd.run()