#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