Files
ss-tools/backend/tests/plugins/test_migration_plugin_password.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

866 lines
39 KiB
Python

#region Test.MigrationPlugin.Password [C:3] [TYPE Module] [SEMANTICS test,migration,password,await_input,regression]
# @BRIEF Regression & integration tests for password injection flow during dashboard migration.
# @RELATION BINDS_TO -> [Plugin.Migration.MigrationPlugin]
# @TEST_EDGE: await_input_no_add_log_callback -> Regression: await_input called without add_log_callback kwarg
# @TEST_EDGE: password_injection_full_flow -> await_input payload verified, wait_for_input called, retry with passwords
# @TEST_EDGE: password_error_unknown_pattern -> Falls back to db_name="unknown" when no regex matches
# @TEST_EDGE: multiple_dashboards_partial_password -> Some dashboards succeed, some fail on password
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from src.plugins.migration import MigrationPlugin
# ── Helpers (mirror from test_migration_plugin.py) ──
def _make_env(id_val="env-1", name="Source Env"):
env = MagicMock()
env.id = id_val
env.name = name
env.url = "https://superset.example.com"
env.username = "admin"
env.password = "secret"
return env
def _make_dashboard(dash_id=1, title="Test Dash"):
return {"id": dash_id, "slug": f"slug-{dash_id}", "dashboard_title": title}
def _make_mock_superset_client():
client = MagicMock()
client.aclose = AsyncMock()
return client
def _make_mock_mapping_service():
svc = MagicMock()
svc.sync_environment = AsyncMock()
return svc
def _make_mock_ctf(path="/tmp/test.zip"):
mock_ctf = MagicMock()
mock_ctf.return_value.__enter__ = MagicMock(return_value=path)
return mock_ctf
# ══════════════════════════════════════════════════════════════════════════════
# CORE REGRESSION: await_input called without add_log_callback
# ══════════════════════════════════════════════════════════════════════════════
class TestPasswordInjectionRegression:
"""Verify the await_input call signature — the bug from production logs."""
# #region Test.MigrationPlugin.TestAwaitInputNoAddLogCallback [C:2] [TYPE Function]
# @BRIEF Regression: tm.await_input() must NOT receive add_log_callback kwarg.
@pytest.mark.asyncio
async def test_await_input_no_add_log_callback(self):
"""
Production bug (2026-07-16): migration plugin passed add_log_callback=add_log
to tm.await_input(), which raised TypeError because manager.await_input()
only accepts (task_id, input_request). Manager handles add_log_callback
internally via self._add_log.
"""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {"ClickHouse": "s3cret"}}
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Dash")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError(
"Must provide a password for the database databases/Dev_Clickhouse_Node_1.yaml"
),
None,
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
result = await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
"_task_id": "task-regress-1",
})
assert result["status"] == "SUCCESS"
# KEY ASSERTION: await_input was called WITHOUT add_log_callback
mock_task_manager.await_input.assert_called_once_with(
"task-regress-1",
{
"type": "database_password",
"databases": ["Dev_Clickhouse_Node_1"],
"error_message": "A database password is required to continue this migration.",
},
)
# #endregion Test.MigrationPlugin.TestAwaitInputNoAddLogCallback
# #region Test.MigrationPlugin.TestAwaitInputCalledWithoutExtraKwargs [C:2] [TYPE Function]
# @BRIEF Regression: await_input call must not include any kwargs beyond task_id + input_request.
@pytest.mark.asyncio
async def test_await_input_called_without_extra_kwargs(self):
"""Explicitly verify no extra keyword arguments leak into await_input call."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {"PG": "pwd"}}
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Dash")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError("Must provide a password for the database 'PostgreSQL'"),
None,
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
"_task_id": "task-extra-kwargs-1",
})
# Verify call signature: exactly 2 positional args, no extra kwargs
call_args = mock_task_manager.await_input.call_args
assert len(call_args[0]) == 2 # task_id, input_request (positional)
assert call_args[1] == {} # no keyword arguments
assert call_args[0][0] == "task-extra-kwargs-1"
assert call_args[0][1]["type"] == "database_password"
# #endregion Test.MigrationPlugin.TestAwaitInputCalledWithoutExtraKwargs
# ══════════════════════════════════════════════════════════════════════════════
# FULL FLOW: password injection end-to-end verification
# ══════════════════════════════════════════════════════════════════════════════
class TestPasswordInjectionFullFlow:
"""Verify the complete password injection lifecycle."""
# #region Test.MigrationPlugin.TestFullFlowWaitForInputCalled [C:2] [TYPE Function]
# @BRIEF After await_input, wait_for_input is called before reading passwords.
@pytest.mark.asyncio
async def test_full_flow_wait_for_input_called(self):
"""Verify wait_for_input is called after await_input, before get_task."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
call_order = []
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {"ClickHouse": "secret"}}
)
async def _await_input(task_id, input_request):
call_order.append("await_input")
async def _wait_for_input(task_id):
call_order.append("wait_for_input")
mock_task_manager.await_input = _await_input
mock_task_manager.wait_for_input = _wait_for_input
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Dash")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError("Must provide a password for the database 'ClickHouse'"),
None,
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
result = await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
"_task_id": "task-order-1",
})
assert result["status"] == "SUCCESS"
# Call order must be: await_input → wait_for_input → get_task
assert call_order == ["await_input", "wait_for_input"]
# #endregion Test.MigrationPlugin.TestFullFlowWaitForInputCalled
# #region Test.MigrationPlugin.TestFullFlowImportRetryWithPasswords [C:2] [TYPE Function]
# @BRIEF After password injection, import is retried with passwords parameter.
@pytest.mark.asyncio
async def test_full_flow_import_retry_with_passwords(self):
"""Verify the retry import_dashboard call includes the passwords kwarg."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {"Dev_Clickhouse": "p@ssw0rd"}}
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Dash")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError("Must provide a password for the database 'Dev_Clickhouse'"),
None,
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
"_task_id": "task-retry-1",
})
# Verify import_dashboard was called twice (fail + retry)
assert mock_tgt_client.import_dashboard.call_count == 2
# Second call (retry) must include passwords kwarg (normalized path + short key)
retry_kwargs = mock_tgt_client.import_dashboard.call_args_list[1][1]
assert "passwords" in retry_kwargs
assert retry_kwargs["passwords"]["Dev_Clickhouse"] == "p@ssw0rd"
assert retry_kwargs["passwords"]["databases/Dev_Clickhouse.yaml"] == "p@ssw0rd"
# #endregion Test.MigrationPlugin.TestFullFlowImportRetryWithPasswords
# #region Test.MigrationPlugin.TestFullFlowPasswordsCleanedAfterRetry [C:2] [TYPE Function]
# @BRIEF Password params must be deleted from task after successful retry.
@pytest.mark.asyncio
async def test_full_flow_passwords_cleaned_after_retry(self):
"""Verify passwords are popped from task.params after retry (security)."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
task_mock_params = {"passwords": {"PG": "secret123"}}
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params=task_mock_params
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Dash")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError("Must provide a password for the database 'PG'"),
None,
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
"_task_id": "task-clean-1",
})
# After retry, passwords must be removed from task params
assert "passwords" not in task_mock_params
# #endregion Test.MigrationPlugin.TestFullFlowPasswordsCleanedAfterRetry
# ══════════════════════════════════════════════════════════════════════════════
# EDGE CASES: unknown db_name patterns, multi-dashboard
# ══════════════════════════════════════════════════════════════════════════════
class TestPasswordInjectionEdgeCases:
"""Cover edge cases for the password error handling."""
# #region Test.MigrationPlugin.TestPasswordErrorUnknownPattern [C:2] [TYPE Function]
# @BRIEF Edge: password error message with format not matching known regex → db_name="unknown".
@pytest.mark.asyncio
async def test_password_error_unknown_pattern(self):
"""When the error message doesn't match databases/*.yaml or 'db_name', fallback to 'unknown'."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {}} # empty passwords → retry fails → partial
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Broken Import")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
# Error message with no recognizable db name pattern at all
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError("Must provide a password for the database"),
None,
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
"_task_id": "task-unknown-1",
})
# Fallback to "unknown" in the input_request
mock_task_manager.await_input.assert_called_once_with(
"task-unknown-1",
{
"type": "database_password",
"databases": ["unknown"],
"error_message": "A database password is required to continue this migration.",
},
)
# #endregion Test.MigrationPlugin.TestPasswordErrorUnknownPattern
# #region Test.MigrationPlugin.TestPartialPasswordMultipleDashboards [C:2] [TYPE Function]
# @BRIEF Edge: 3 dashboards — 1st succeeds, 2nd password error → recovered, 3rd succeeds.
@pytest.mark.asyncio
async def test_partial_password_multiple_dashboards(self):
"""Multi-dashboard migration where one hits a password error and recovers."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {"ClickHouse": "p@ss"}}
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
dashboards = [
_make_dashboard(1, "Dash A"),
_make_dashboard(2, "Dash B (needs password)"),
_make_dashboard(3, "Dash C"),
]
mock_src_client.get_dashboards = AsyncMock(return_value=(True, dashboards))
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
# Dash 1 → success, Dash 2 → password error → retry success, Dash 3 → success
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
None, # dash 1: success
RuntimeError(
"Must provide a password for the database databases/ClickHouse.yaml"
), # dash 2: fail
None, # dash 2: retry success
None, # dash 3: success
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
result = await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1, 2, 3],
"replace_db_config": False,
"_task_id": "task-multi-1",
})
# All 3 dashboards should succeed (2nd via password injection)
assert result["status"] == "SUCCESS"
assert len(result["migrated_dashboards"]) == 3
assert len(result["failed_dashboards"]) == 0
# await_input should be called exactly once (for dash 2)
assert mock_task_manager.await_input.call_count == 1
assert mock_task_manager.wait_for_input.call_count == 1
# import_dashboard called 4 times (dash1, dash2-fail, dash2-retry, dash3)
assert mock_tgt_client.import_dashboard.call_count == 4
# #endregion Test.MigrationPlugin.TestPartialPasswordMultipleDashboards
# #region Test.MigrationPlugin.TestPasswordErrorYamlWithDotsInName [C:2] [TYPE Function]
# @BRIEF Edge: YAML filename with dots (e.g. Dev_Clickhouse_Node_1.yaml) → correct extraction.
@pytest.mark.asyncio
async def test_password_error_yaml_with_dots_in_name(self):
"""Path like databases/Dev_Clickhouse_Node_1.yaml with dots → extract correctly."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {"Dev_Clickhouse_Node_1": "pwd123"}}
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Dash")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError(
"Must provide a password for the database databases/Dev_Clickhouse_Node_1.yaml"
),
None,
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
"_task_id": "task-dots-1",
})
# Regex r"databases/([^.]+)\.yaml" extracts up to first dot: "Dev_Clickhouse_Node_1"
mock_task_manager.await_input.assert_called_once_with(
"task-dots-1",
{
"type": "database_password",
"databases": ["Dev_Clickhouse_Node_1"],
"error_message": "A database password is required to continue this migration.",
},
)
# #endregion Test.MigrationPlugin.TestPasswordErrorYamlWithDotsInName
# #region Test.MigrationPlugin.TestPasswordErrorNoTaskIdSkipsAwait [C:2] [TYPE Function]
# @BRIEF Edge: password error without task_id — await_input not called, dashboard marked failed.
@pytest.mark.asyncio
async def test_password_error_no_task_id_skips_await(self):
"""When _task_id is not provided, skip await_input and mark dashboard as failed."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Dash")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError(
"Must provide a password for the database databases/ClickHouse.yaml"
),
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
result = await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
# No _task_id — should skip await_input
})
# await_input must NOT be called without task_id
mock_task_manager.await_input.assert_not_called()
# Dashboard must be marked as failed
assert len(result["failed_dashboards"]) == 1
assert result["failed_dashboards"][0]["title"] == "Dash"
# #endregion Test.MigrationPlugin.TestPasswordErrorNoTaskIdSkipsAwait
# #region Test.MigrationPlugin.TestPasswordRetryFailureDoesNotAbortBatch [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_password_retry_failure_does_not_abort_batch(self):
"""Password retry fails for dash 1; dash 2 still migrates successfully."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {"ClickHouse": "wrong"}}
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(
True,
[_make_dashboard(1, "Needs Password"), _make_dashboard(2, "OK Dash")],
)
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError(
"Must provide a password for the database databases/ClickHouse.yaml"
),
RuntimeError("Still invalid password"),
None, # dash 2 succeeds
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
result = await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1, 2],
"_task_id": "task-retry-fail",
})
assert result["status"] == "PARTIAL_SUCCESS"
assert len(result["migrated_dashboards"]) == 1
assert result["migrated_dashboards"][0]["title"] == "OK Dash"
assert len(result["failed_dashboards"]) == 1
assert result["failed_dashboards"][0]["title"] == "Needs Password"
# Retry must send normalized Superset path keys
retry_call = mock_tgt_client.import_dashboard.call_args_list[1]
passwords = retry_call.kwargs.get("passwords") or retry_call[1].get("passwords")
assert passwords is not None
assert "databases/ClickHouse.yaml" in passwords
# #endregion Test.MigrationPlugin.TestPasswordRetryFailureDoesNotAbortBatch
# ══════════════════════════════════════════════════════════════════════════════
# INTEGRATION-STYLE: cross-filter + password injection interplay
# ══════════════════════════════════════════════════════════════════════════════
class TestPasswordInjectionIntegration:
"""Integration-style tests combining password injection with other migration features."""
# #region Test.MigrationPlugin.TestFixCrossFiltersPassedToTransformZip [C:2] [TYPE Function]
# @BRIEF Integration: fix_cross_filters param flows through to transform_zip even during password errors.
@pytest.mark.asyncio
async def test_fix_cross_filters_passed_to_transform_zip(self):
"""Verify fix_cross_filters=True reaches transform_zip during password injection flow."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {"PG": "s3cret"}}
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Dash")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError("Must provide a password for the database 'PG'"),
None,
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
await plugin.execute({
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
"fix_cross_filters": True,
"_task_id": "task-xfilt-1",
})
# transform_zip must be called with fix_cross_filters=True
assert mock_engine.transform_zip.call_count == 1
transform_kwargs = mock_engine.transform_zip.call_args[1]
assert transform_kwargs["fix_cross_filters"] is True
# #endregion Test.MigrationPlugin.TestFixCrossFiltersPassedToTransformZip
# #region Test.MigrationPlugin.TestPasswordInjectionWithTaskContext [C:2] [TYPE Function]
# @BRIEF Integration: password injection flow works correctly with TaskContext logger.
@pytest.mark.asyncio
async def test_password_injection_with_task_context(self):
"""Ensure password injection works when TaskContext is provided (production path)."""
plugin = MigrationPlugin()
src_env = _make_env("env-1", "Source")
tgt_env = _make_env("env-2", "Target")
mock_cm = MagicMock()
mock_cm.get_environments.return_value = [src_env, tgt_env]
mock_task_manager = MagicMock()
mock_task_manager.get_task.return_value = MagicMock(
params={"passwords": {"PG": "pwd"}}
)
mock_task_manager.await_input = AsyncMock()
mock_task_manager.wait_for_input = AsyncMock()
mock_src_client = _make_mock_superset_client()
mock_src_client.get_dashboards = AsyncMock(
return_value=(True, [_make_dashboard(1, "Dash")])
)
mock_src_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta"))
mock_tgt_client = _make_mock_superset_client()
mock_tgt_client.import_dashboard = AsyncMock(
side_effect=[
RuntimeError("Must provide a password for the database 'PG'"),
None,
]
)
mock_engine = MagicMock()
mock_engine.transform_zip.return_value = True
# Create a TaskContext with a mock logger
ctx = MagicMock()
ctx.logger = MagicMock()
ctx.logger.with_source.return_value = MagicMock()
with patch("src.plugins.migration.get_config_manager", return_value=mock_cm), \
patch("src.plugins.migration.SupersetClient") as MockSC, \
patch("src.plugins.migration.MigrationEngine", return_value=mock_engine), \
patch("src.plugins.migration.create_temp_file", return_value=_make_mock_ctf()), \
patch("src.dependencies.get_task_manager", return_value=mock_task_manager), \
patch("src.plugins.migration.IdMappingService", return_value=_make_mock_mapping_service()), \
patch("src.plugins.migration.SessionLocal"):
MockSC.side_effect = [mock_src_client, mock_tgt_client]
result = await plugin.execute(
{
"source_env_id": "env-1",
"target_env_id": "env-2",
"selected_ids": [1],
"replace_db_config": False,
"_task_id": "task-ctx-1",
},
context=ctx,
)
assert result["status"] == "SUCCESS"
assert len(result["migrated_dashboards"]) == 1
# #endregion Test.MigrationPlugin.TestPasswordInjectionWithTaskContext
# #endregion Test.MigrationPlugin.Password