Files
ss-tools/backend/tests/plugins/test_backup_plugin.py
root 632b730fff chore: migrate GRACE-Poly anchors to hierarchical dotted naming
Systematic rename of all semantic anchors (#region, [DEF], @RELATION)
across 1400+ files — backend Python, frontend Svelte/TS, specs, docs:
- Flat anchors become Namespace.Module.Entity
- @RELATION references updated to match new anchor paths
- Zero business logic changes
2026-07-22 11:48:15 +03:00

465 lines
18 KiB
Python

# #region Test.BackupPlugin [C:3] [TYPE Module] [SEMANTICS test, backup, plugin, coverage, integrity]
# @BRIEF Unit tests for BackupPlugin — properties, get_schema, execute, integrity metadata, and edge cases.
# @RELATION BINDS_TO -> [Plugin.Backup.BackupPlugin]
# @TEST_EDGE: missing_env -> Raises KeyError
# @TEST_EDGE: no_dashboards -> Returns NO_DASHBOARDS status
# @TEST_EDGE: partial_failure -> Returns PARTIAL_SUCCESS with failed_dashboards
# @TEST_EDGE: empty_environments -> get_schema returns empty list
# @TEST_EDGE: integrity_metadata -> dashboard entries include archive_sha256, content_hash, integrity_status, manifest_path
import pytest
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
from pathlib import Path
from requests.exceptions import RequestException
from src.plugins.backup import BackupPlugin
# ── Helpers ──
def _make_env(id_val="env-1", name="Test Env"):
env = MagicMock()
env.id = id_val
env.name = name
return env
def _make_task_context():
ctx = MagicMock()
ctx.logger = MagicMock()
ctx.logger.with_source.return_value = MagicMock()
return ctx
def _make_dashboard(dash_id=1, title="Test Dash"):
return {"id": dash_id, "dashboard_title": title}
class TestBackupPluginProperties:
"""Verify static property values."""
def test_id(self):
plugin = BackupPlugin()
assert plugin.id == "superset-backup"
def test_name(self):
plugin = BackupPlugin()
assert plugin.name == "Superset Dashboard Backup"
def test_description(self):
plugin = BackupPlugin()
assert plugin.description == "Backs up all dashboards from a Superset instance."
def test_version(self):
plugin = BackupPlugin()
assert plugin.version == "1.0.0"
def test_ui_route(self):
plugin = BackupPlugin()
assert plugin.ui_route == "/tools/backups"
class TestBackupPluginGetSchema:
"""Verify get_schema — dynamic schema based on environments."""
def test_get_schema_with_envs(self):
plugin = BackupPlugin()
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [_make_env("e1", "Dev"), _make_env("e2", "Prod")]
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm):
schema = plugin.get_schema()
assert schema["type"] == "object"
assert schema["properties"]["env"]["enum"] == ["Dev", "Prod"]
assert "env" in schema["required"]
def test_get_schema_no_envs(self):
"""Fallback to empty list when no environments configured."""
plugin = BackupPlugin()
mock_cm = MagicMock()
mock_cm.get_environments.return_value = []
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm):
schema = plugin.get_schema()
assert schema["properties"]["env"]["enum"] == []
class TestBackupPluginExecute:
"""Verify BackupPlugin.execute with various scenarios."""
# ── Missing env → KeyError ──
@pytest.mark.asyncio
async def test_execute_missing_env(self):
"""Missing env param raises KeyError."""
plugin = BackupPlugin()
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = []
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
pytest.raises(KeyError, match="env"):
await plugin.execute({})
@pytest.mark.asyncio
async def test_execute_missing_env_with_ctx(self):
"""Missing env param raises KeyError even with context."""
plugin = BackupPlugin()
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = []
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
ctx = _make_task_context()
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
pytest.raises(KeyError, match="env"):
await plugin.execute({"other": "value"}, context=ctx)
# ── No dashboards ──
@pytest.mark.asyncio
async def test_execute_no_dashboards(self):
"""Zero dashboards returns NO_DASHBOARDS status."""
plugin = BackupPlugin()
env = _make_env("env-1", "Source")
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = [env]
mock_cm.get_environment.return_value = env
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
mock_client = MagicMock()
mock_client.get_dashboards = AsyncMock(return_value=(0, []))
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
patch('src.plugins.backup.SupersetClient', return_value=mock_client):
result = await plugin.execute({"env": "Source"})
assert result["status"] == "NO_DASHBOARDS"
assert result["total_dashboards"] == 0
assert result["backed_up_dashboards"] == 0
assert result["failed_dashboards"] == 0
# ── Partial success ──
@pytest.mark.asyncio
async def test_execute_partial_failure(self):
"""One dashboard fails, one succeeds → PARTIAL_SUCCESS."""
plugin = BackupPlugin()
env = _make_env("env-1", "Source")
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = [env]
mock_cm.get_environment.return_value = env
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
dashboards = [
_make_dashboard(1, "Good Dash"),
_make_dashboard(2, "Bad Dash"),
]
mock_client = MagicMock()
mock_client.get_dashboards = AsyncMock(return_value=(2, dashboards))
mock_client.export_dashboard = AsyncMock(side_effect=[
(b"zip_content", "good.zip"),
RequestException("Export failed"),
])
ctx = _make_task_context()
ctx.logger.progress = MagicMock()
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
patch('src.plugins.backup.run_blocking') as mock_run:
mock_run.return_value = None
result = await plugin.execute({"env": "Source"}, context=ctx)
assert result["status"] == "PARTIAL_SUCCESS"
assert result["total_dashboards"] == 2
assert result["backed_up_dashboards"] == 1
assert result["failed_dashboards"] == 1
assert result["dashboards"][0]["title"] == "Good Dash"
assert result["failures"][0]["title"] == "Bad Dash"
# ── Full success with environment_id resolution ──
@pytest.mark.asyncio
async def test_execute_with_environment_id(self):
"""environment_id param resolves to env name."""
plugin = BackupPlugin()
env = _make_env("env-abc", "ResolvedEnv")
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = [env]
mock_cm.get_environment.return_value = env
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
mock_client = MagicMock()
mock_client.get_dashboards = AsyncMock(return_value=(0, []))
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
patch('src.plugins.backup.SupersetClient', return_value=mock_client):
result = await plugin.execute({"environment_id": "env-abc"})
assert result["status"] == "NO_DASHBOARDS"
assert result["environment"] == "ResolvedEnv"
# ── Dashboard ID filter ──
@pytest.mark.asyncio
async def test_execute_dashboard_ids_filter(self):
"""Filtering by specific dashboard IDs."""
plugin = BackupPlugin()
env = _make_env("env-1", "Source")
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = [env]
mock_cm.get_environment.return_value = env
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
all_dashboards = [
_make_dashboard(1, "Dash A"),
_make_dashboard(2, "Dash B"),
_make_dashboard(3, "Dash C"),
]
mock_client = MagicMock()
mock_client.get_dashboards = AsyncMock(return_value=(3, all_dashboards))
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta.zip"))
ctx = _make_task_context()
ctx.logger.progress = MagicMock()
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
patch('src.plugins.backup.run_blocking') as mock_run:
mock_run.return_value = None
result = await plugin.execute({"env": "Source", "dashboard_ids": ["1", "3"]}, context=ctx)
assert result["status"] == "SUCCESS"
assert result["total_dashboards"] == 2
assert result["dashboards"][0]["id"] == 1
assert result["dashboards"][1]["id"] == 3
# ── No env configured ──
@pytest.mark.asyncio
async def test_execute_no_environments_configured(self):
"""has_environments returns False → raise ValueError."""
plugin = BackupPlugin()
mock_cm = MagicMock()
mock_cm.has_environments.return_value = False
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
pytest.raises(ValueError, match="No Superset environments configured"):
await plugin.execute({"env": "Source"})
# ── Environment not found ──
@pytest.mark.asyncio
async def test_execute_environment_not_found(self):
"""get_environment returns None → raise ValueError."""
plugin = BackupPlugin()
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = [_make_env("other", "Other")]
mock_cm.get_environment.return_value = None
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
pytest.raises(ValueError, match="not found"):
await plugin.execute({"env": "NonExistent"})
# ── Fatal error wraps OSError ──
@pytest.mark.asyncio
async def test_execute_oserror_raised(self):
"""OSError during execution propagates."""
plugin = BackupPlugin()
env = _make_env("env-1", "Source")
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = [env]
mock_cm.get_environment.return_value = env
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
mock_client = MagicMock()
mock_client.get_dashboards = AsyncMock(side_effect=OSError("Disk full"))
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
pytest.raises(OSError, match="Disk full"):
await plugin.execute({"env": "Source"})
# ── Missing dashboard_id in metadata ──
@pytest.mark.asyncio
async def test_execute_skip_dashboard_without_id(self):
"""Dashboard without id field is skipped."""
plugin = BackupPlugin()
env = _make_env("env-1", "Source")
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = [env]
mock_cm.get_environment.return_value = env
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
dashboards = [
{"dashboard_title": "No ID Dash"}, # no 'id' key
_make_dashboard(2, "Valid Dash"),
]
mock_client = MagicMock()
mock_client.get_dashboards = AsyncMock(return_value=(2, dashboards))
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta.zip"))
ctx = _make_task_context()
ctx.logger.progress = MagicMock()
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
patch('src.plugins.backup.run_blocking') as mock_run:
mock_run.return_value = None
result = await plugin.execute({"env": "Source"}, context=ctx)
assert result["status"] == "SUCCESS"
assert result["total_dashboards"] == 2
assert result["backed_up_dashboards"] == 1
assert result["dashboards"][0]["title"] == "Valid Dash"
# ── Invalid dashboard_id string in filter ──
@pytest.mark.asyncio
async def test_execute_invalid_dashboard_id_string(self):
"""Non-numeric dashboard_ids raises ValueError."""
plugin = BackupPlugin()
env = _make_env("env-1", "Source")
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = [env]
mock_cm.get_environment.return_value = env
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = "/tmp"
mock_cm.get_config.return_value = mock_cfg
mock_client = MagicMock()
mock_client.get_dashboards = AsyncMock(return_value=(0, []))
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
pytest.raises(ValueError, match="Invalid dashboard_ids"):
await plugin.execute({"env": "Source", "dashboard_ids": ["abc", "123"]})
# ── Integrity metadata in result ──
@pytest.mark.asyncio
async def test_execute_with_integrity_metadata(self, tmp_path):
"""Dashboard entries include archive_sha256, content_hash, integrity_status, manifest_path
when ZIP files exist on disk after export."""
plugin = BackupPlugin()
env = _make_env("env-1", "Source")
mock_cm = MagicMock()
mock_cm.has_environments.return_value = True
mock_cm.get_environments.return_value = [env]
mock_cm.get_environment.return_value = env
mock_cfg = MagicMock()
mock_cfg.settings.storage.root_path = str(tmp_path)
mock_cm.get_config.return_value = mock_cfg
dashboards = [_make_dashboard(1, "Integrity Dash")]
mock_client = MagicMock()
mock_client.get_dashboards = AsyncMock(return_value=(1, dashboards))
# Create a valid ZIP archive in memory
import io
import zipfile as zf_mod
zip_buffer = io.BytesIO()
with zf_mod.ZipFile(zip_buffer, "w", zf_mod.ZIP_DEFLATED) as zf:
zf.writestr("dashboard_metadata.yaml", "dashboard_name: Integrity Dash\n")
zf.writestr("charts/chart_1.yaml", "chart_name: Test Chart\n")
valid_zip_bytes = zip_buffer.getvalue()
mock_client.export_dashboard = AsyncMock(return_value=(valid_zip_bytes, "export.zip"))
ctx = _make_task_context()
ctx.logger.progress = MagicMock()
# We need to intercept run_blocking calls for file ops to make them real
# so that the ZIP file actually appears on disk.
# We'll save the ZIP via real fileio function, then mock the rest.
from src.core.utils.fileio import save_and_unpack_dashboard
actual_results = {}
async def _run_blocking_side_effect(kind='file', fn=None, **kwargs):
if fn is save_and_unpack_dashboard:
# Actually call the real function to create the ZIP on disk
real_result = fn(
zip_content=kwargs.get('zip_content', b''),
original_filename=kwargs.get('original_filename'),
output_dir=kwargs.get('output_dir'),
unpack=False,
)
actual_results['zip_path'] = real_result[0]
return real_result
if fn.__name__ == 'write_backup_manifest':
# Actually call the real function
real_result = fn(**kwargs)
actual_results['manifest_path'] = real_result
return real_result
return None
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
patch('src.plugins.backup.run_blocking', side_effect=_run_blocking_side_effect):
result = await plugin.execute({"env": "Source"}, context=ctx)
assert result["status"] == "SUCCESS"
assert result["total_dashboards"] == 1
assert result["backed_up_dashboards"] == 1
dash_entry = result["dashboards"][0]
assert dash_entry["title"] == "Integrity Dash"
assert dash_entry["archive_sha256"] is not None
assert isinstance(dash_entry["archive_sha256"], str)
assert len(dash_entry["archive_sha256"]) == 64
assert dash_entry["content_hash"] is not None
assert isinstance(dash_entry["content_hash"], str)
assert len(dash_entry["content_hash"]) == 64
assert dash_entry["integrity_status"] == "verified"
assert dash_entry["manifest_path"] is not None
# Verify the manifest file exists on disk
manifest_path = Path(dash_entry["manifest_path"])
assert manifest_path.exists()
import json
manifest = json.loads(manifest_path.read_bytes())
assert manifest["archive_sha256"] == dash_entry["archive_sha256"]
assert manifest["content_hash"] == dash_entry["content_hash"]
assert manifest["integrity_status"] == "verified"
assert manifest["dashboard_id"] == 1
# #endregion Test.BackupPlugin