#region TestSettingsConsolidated [C:3] [TYPE Module] [SEMANTICS test,settings,connections] # @BRIEF Tests for GET/PATCH /api/settings/consolidated — connections typed-model enforcement. # @RELATION BINDS_TO -> [get_consolidated_settings, update_consolidated_settings] # @RELATION DEPENDS_ON -> [EXT:FastAPI:TestClient] # @TEST_CONTRACT [GlobalSettings.connections] -> [ConsolidatedSettingsResponse | 200] # @TEST_EDGE: dict_in_connections -> GET survives list[dict] in connections (no .model_dump crash) # @TEST_EDGE: model_in_connections -> GET works with typed DatabaseConnection models # @TEST_EDGE: empty_connections -> GET returns empty list # @TEST_EDGE: patch_saves_as_model -> PATCH stores DatabaseConnection instances, not raw dicts # @TEST_EDGE: patch_masked_password -> PATCH preserves existing password on "********" # @TEST_EDGE: patch_new_password -> PATCH encrypts new passwords via ConnectionService # @TEST_EDGE: unauthorized -> 403 when user lacks admin role # @TEST_INVARIANT connections_typed -> VERIFIED_BY: patch_saves_as_model, patch_masked_password # @INVARIANT current_settings.connections must always be list[DatabaseConnection], never list[dict] # Set required env vars before ANY app imports — crash-early guard for AuthConfig() import os os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth_settings_consolidated") os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:test_main_settings_consolidated") os.environ.setdefault("DEV_MODE", "true") import pytest from unittest.mock import MagicMock from fastapi.testclient import TestClient from src.app import app from src.core.config_models import ( DatabaseConnection, FeaturesConfig, GlobalSettings, LoggingConfig, ) from src.dependencies import ( get_config_manager, get_current_user, ) # ── Shared mocks ─────────────────────────────────────────────────────── mock_user = MagicMock() mock_user.username = "testuser" mock_user.roles = [] admin_role = MagicMock() admin_role.name = "Admin" admin_role.permissions = [] mock_user.roles.append(admin_role) non_admin_role = MagicMock() non_admin_role.name = "User" non_admin_role.permissions = [] non_admin_role.is_admin = False def _make_conn_dict(**overrides) -> dict: """Build a raw connection dict as the frontend would send it.""" return { "id": overrides.get("id", "conn-1"), "name": overrides.get("name", "Test DB"), "host": overrides.get("host", "localhost"), "port": overrides.get("port", 5432), "database": overrides.get("database", "testdb"), "username": overrides.get("username", "admin"), "password": overrides.get("password", "secret"), "dialect": overrides.get("dialect", "postgresql"), "extra_params": overrides.get("extra_params", {}), "pool_size": overrides.get("pool_size", 5), } def _make_conn_model(**overrides) -> DatabaseConnection: """Build a typed DatabaseConnection model.""" return DatabaseConnection(**_make_conn_dict(**overrides)) # ── Fixtures ─────────────────────────────────────────────────────────── @pytest.fixture(autouse=True) def mock_deps(): """Override FastAPI dependencies for consolidated-settings routes. get_current_user returns an Admin user by default — permission checks pass through the real has_permission function. Sets up a real GlobalSettings so typed-field assignment is testable. """ mock_user.roles = [admin_role] # Real GlobalSettings with known state real_settings = GlobalSettings() real_settings.features = FeaturesConfig(translate=True, migration=False) real_settings.logging = LoggingConfig() # AppConfig mock — .settings is a real GlobalSettings; # .environments is empty to avoid MagicMock noise in responses app_config = MagicMock() app_config.settings = real_settings app_config.environments = [] mock_config_manager = MagicMock() mock_config_manager.get_config.return_value = app_config app.dependency_overrides[get_config_manager] = lambda: mock_config_manager app.dependency_overrides[get_current_user] = lambda: mock_user yield { "config": mock_config_manager, "settings": real_settings, } app.dependency_overrides.clear() client = TestClient(app) # ── GET /api/settings/consolidated ───────────────────────────────────── #region test_get_consolidated_with_model_connections [C:2] [TYPE Function] # @BRIEF GET returns 200 when connections contains DatabaseConnection models. # @TEST_INVARIANT connections_typed -> VERIFIED_BY: test_get_consolidated_with_model_connections def test_get_consolidated_with_model_connections(mock_deps): """DatabaseConnection models in settings.connections → 200, all passwords masked.""" settings: GlobalSettings = mock_deps["settings"] settings.connections = [ _make_conn_model(id="c1", name="Alpha", password="real-pwd-1"), _make_conn_model(id="c2", name="Beta", password="real-pwd-2"), ] response = client.get("/api/settings/consolidated") assert response.status_code == 200 data = response.json() assert len(data["connections"]) == 2 for entry in data["connections"]: assert entry["password"] == "********", "Passwords must be masked in response" assert data["connections"][0]["name"] == "Alpha" assert data["connections"][1]["name"] == "Beta" assert data["features"]["translate"] is True assert data["features"]["migration"] is False #endregion test_get_consolidated_with_model_connections #region test_get_consolidated_with_dict_connections [C:2] [TYPE Function] # @BRIEF GET handles defensive case where connections contains raw dicts from previous corruption. # @TEST_EDGE: dict_in_connections -> GET does not crash def test_get_consolidated_with_dict_connections(mock_deps): """Raw dicts in settings.connections → 200 (no .model_dump crash).""" settings: GlobalSettings = mock_deps["settings"] settings.connections = [ # ← dicts, not DatabaseConnection models {"id": "c1", "name": "Alpha", "password": "p1", "host": "h1", "port": 5432, "database": "d1", "username": "u1", "dialect": "postgresql"}, {"id": "c2", "name": "Beta", "password": "p2", "host": "h2", "port": 5432, "database": "d2", "username": "u2", "dialect": "postgresql"}, ] response = client.get("/api/settings/consolidated") assert response.status_code == 200 data = response.json() assert len(data["connections"]) == 2 for entry in data["connections"]: assert entry["password"] == "********" assert data["connections"][0]["name"] == "Alpha" #endregion test_get_consolidated_with_dict_connections #region test_get_consolidated_empty_connections [C:2] [TYPE Function] # @BRIEF GET returns empty list when no connections configured. # @TEST_EDGE: empty_connections -> GET returns [] def test_get_consolidated_empty_connections(mock_deps): """Empty connections list → 200, connections=[], nothing crashes.""" settings: GlobalSettings = mock_deps["settings"] settings.connections = [] response = client.get("/api/settings/consolidated") assert response.status_code == 200 data = response.json() assert data["connections"] == [] #endregion test_get_consolidated_empty_connections #region test_get_consolidated_unauthorized [C:2] [TYPE Function] # @BRIEF GET returns 403 when user lacks admin:settings:READ permission. # @TEST_EDGE: unauthorized -> 403 def test_get_consolidated_unauthorized(): """Non-admin user → 403.""" mock_user.roles = [non_admin_role] response = client.get("/api/settings/consolidated") assert response.status_code == 403 #endregion test_get_consolidated_unauthorized # ── PATCH /api/settings/consolidated ─────────────────────────────────── #region test_patch_connections_saves_as_model [C:2] [TYPE Function] # @BRIEF PATCH with connections dicts stores them as DatabaseConnection models. # @TEST_INVARIANT connections_typed -> VERIFIED_BY: test_patch_connections_saves_as_model def test_patch_connections_saves_as_model(mock_deps): """After PATCH, current_settings.connections contains DatabaseConnection instances, not dicts.""" mock_config = mock_deps["config"] payload = { "connections": [ _make_conn_dict(id="c1", name="Prod DB", password="p@ss"), ] } response = client.patch("/api/settings/consolidated", json=payload) assert response.status_code == 200 # Verify via update_global_settings call — connections must be typed mock_config.update_global_settings.assert_called_once() args, _ = mock_config.update_global_settings.call_args updated_settings: GlobalSettings = args[0] assert len(updated_settings.connections) == 1 conn = updated_settings.connections[0] assert isinstance(conn, DatabaseConnection), ( f"Expected DatabaseConnection, got {type(conn).__name__}" ) assert conn.id == "c1" assert conn.name == "Prod DB" assert conn.host == "localhost" assert conn.dialect == "postgresql" #endregion test_patch_connections_saves_as_model #region test_patch_connections_masked_password [C:2] [TYPE Function] # @BRIEF PATCH with "********" password preserves the existing encrypted password. # @TEST_EDGE: patch_masked_password -> existing password not overwritten def test_patch_connections_masked_password(mock_deps): """Masked password "********" → keeps encrypted password from existing config.""" settings: GlobalSettings = mock_deps["settings"] settings.connections = [ _make_conn_model(id="c-exist", name="Existing", password="encrypted-vault-value"), ] mock_config = mock_deps["config"] payload = { "connections": [ _make_conn_dict(id="c-exist", name="Existing", password="********"), ] } response = client.patch("/api/settings/consolidated", json=payload) assert response.status_code == 200 mock_config.update_global_settings.assert_called_once() args, _ = mock_config.update_global_settings.call_args updated_settings: GlobalSettings = args[0] conn = updated_settings.connections[0] assert conn.password == "encrypted-vault-value", ( "Must preserve existing password when masked password sent" ) #endregion test_patch_connections_masked_password #region test_patch_connections_new_password_encrypted [C:2] [TYPE Function] # @BRIEF PATCH with a new plaintext password encrypts it via ConnectionService. # @TEST_EDGE: patch_new_password -> encrypts via ConnectionService._encrypt_password def test_patch_connections_new_password_encrypted(mock_deps): """New plaintext password → ConnectionService._encrypt_password called.""" settings: GlobalSettings = mock_deps["settings"] # Existing connection with an encrypted password settings.connections = [ _make_conn_model(id="c-old", name="Old Conn", password="stale-encrypted"), ] mock_config = mock_deps["config"] payload = { "connections": [ _make_conn_dict(id="c-old", name="Old Conn", password="new-plain-password"), ] } response = client.patch("/api/settings/consolidated", json=payload) assert response.status_code == 200 # Password is NOT "********" and NOT "stale-encrypted" — it goes through # ConnectionService._encrypt_password which (without real EncryptionManager) # returns the password as-is or encrypts it. At minimum we verify it changed. mock_config.update_global_settings.assert_called_once() args, _ = mock_config.update_global_settings.call_args updated_settings: GlobalSettings = args[0] conn = updated_settings.connections[0] assert conn.password != "********", "New password must not be masked" assert conn.password != "stale-encrypted", "New password must replace old encrypted value" assert conn.name == "Old Conn" #endregion test_patch_connections_new_password_encrypted #region test_patch_connections_multiple_at_once [C:2] [TYPE Function] # @BRIEF PATCH processes multiple connections in one request. def test_patch_connections_multiple_at_once(mock_deps): """Three connections in one PATCH → all stored as DatabaseConnection models.""" mock_config = mock_deps["config"] payload = { "connections": [ _make_conn_dict(id="c1", name="Alpha", password="p1"), _make_conn_dict(id="c2", name="Beta", password="p2"), _make_conn_dict(id="c3", name="Gamma", password="p3"), ] } response = client.patch("/api/settings/consolidated", json=payload) assert response.status_code == 200 mock_config.update_global_settings.assert_called_once() args, _ = mock_config.update_global_settings.call_args updated_settings: GlobalSettings = args[0] assert len(updated_settings.connections) == 3 for conn in updated_settings.connections: assert isinstance(conn, DatabaseConnection) assert [c.name for c in updated_settings.connections] == ["Alpha", "Beta", "Gamma"] #endregion test_patch_connections_multiple_at_once #region test_patch_connections_empty_list [C:2] [TYPE Function] # @BRIEF PATCH with empty connections list clears the list. def test_patch_connections_empty_list(mock_deps): """Empty connections list in PATCH → connections cleared.""" settings: GlobalSettings = mock_deps["settings"] settings.connections = [ _make_conn_model(id="c1", name="To Delete", password="secret"), ] mock_config = mock_deps["config"] payload = {"connections": []} response = client.patch("/api/settings/consolidated", json=payload) assert response.status_code == 200 mock_config.update_global_settings.assert_called_once() args, _ = mock_config.update_global_settings.call_args updated_settings: GlobalSettings = args[0] assert updated_settings.connections == [] #endregion test_patch_connections_empty_list #region test_patch_settings_without_connections [C:2] [TYPE Function] # @BRIEF PATCH without connections only updates other settings (features, timezone). def test_patch_settings_without_connections(mock_deps): """No 'connections' key in payload → connections left untouched.""" settings: GlobalSettings = mock_deps["settings"] settings.connections = [ _make_conn_model(id="c1", name="Existing Conn", password="secret"), ] mock_config = mock_deps["config"] payload = { "features": {"translate": False, "migration": True}, "app_timezone": "Asia/Tokyo", } response = client.patch("/api/settings/consolidated", json=payload) assert response.status_code == 200 mock_config.update_global_settings.assert_called_once() args, _ = mock_config.update_global_settings.call_args updated_settings: GlobalSettings = args[0] # Connections preserved assert len(updated_settings.connections) == 1 assert updated_settings.connections[0].name == "Existing Conn" # Features updated assert updated_settings.features.translate is False assert updated_settings.features.migration is True # Timezone updated assert updated_settings.app_timezone == "Asia/Tokyo" #endregion test_patch_settings_without_connections #region test_patch_unauthorized [C:2] [TYPE Function] # @BRIEF PATCH returns 403 when user lacks admin:settings:WRITE permission. # @TEST_EDGE: unauthorized -> 403 def test_patch_unauthorized(): """Non-admin user → 403.""" mock_user.roles = [non_admin_role] payload = {"features": {"translate": False}} response = client.patch("/api/settings/consolidated", json=payload) assert response.status_code == 403 #endregion test_patch_unauthorized #endregion TestSettingsConsolidated