- Dockerfile.agent: fix CMD (python -m src.agent.run), use backend/requirements.txt, minimal COPY (only src.agent + src.core.cot_logger), add GRACE contract - docker-compose.yml: SERVICE_TOKEN_SECRET -> SERVICE_JWT (match code) - docker-compose.enterprise-clean.yml: same env var fix - docker/.env.agent.example: same env var fix - build.sh: same env var fix - chore: semantics-testing SKILL.md, backend tests, pyproject.toml
387 lines
16 KiB
Python
387 lines
16 KiB
Python
#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
|