mirror of
https://github.com/apache/superset.git
synced 2026-06-01 21:59:26 +00:00
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
188 lines
7.2 KiB
Python
188 lines
7.2 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_owned_and_soft_deleted(
|
|
app_context: None,
|
|
) -> None:
|
|
"""A soft-deleted row owned by the caller passes the ownership 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_ownership = MagicMock(return_value=None)
|
|
result = cmd.validate()
|
|
|
|
assert result is soft_deleted
|
|
mock_sec.raise_for_ownership.assert_called_once_with(soft_deleted)
|
|
|
|
|
|
def test_validate_raises_forbidden_when_ownership_check_fails(
|
|
app_context: None,
|
|
) -> None:
|
|
"""The security manager's raise_for_ownership raises
|
|
``SupersetSecurityException`` for non-owners; 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_ownership(_resource: object) -> None:
|
|
raise SupersetSecurityException(MagicMock())
|
|
|
|
with patch("superset.commands.restore.security_manager") as mock_sec:
|
|
mock_sec.raise_for_ownership = reject_ownership
|
|
with pytest.raises(_ForbiddenError):
|
|
cmd.validate()
|
|
|
|
|
|
def test_validate_calls_dao_with_visibility_bypass_only(app_context: None) -> None:
|
|
"""The DAO load uses ``skip_visibility_filter=True`` (so the
|
|
soft-deleted row is visible) and ``id_column='uuid'`` — but does
|
|
NOT bypass the entity's ``base_filter``. Restore should honor RBAC
|
|
the same way ``delete`` does (which loads through ``find_by_ids``
|
|
without ``skip_base_filter=True``); the visibility bypass is the
|
|
only escape hatch needed for restore."""
|
|
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_ownership = MagicMock(return_value=None)
|
|
cmd.validate()
|
|
|
|
cmd.dao.find_by_id.assert_called_once_with(
|
|
"uuid-1",
|
|
id_column="uuid",
|
|
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_ownership = 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_ownership = MagicMock(return_value=None)
|
|
with pytest.raises(_RestoreFailedError):
|
|
cmd.run()
|