test(backend): add 55+ test files to push coverage to 98%
Subagents delivered tests across all uncovered backend modules: Schemas (100%): agent, auth, health, profile, settings, validation Services (98-100%): auth, profile, health, llm, mapping, resource, security, git, superset_lookup, sql_table_extractor, rbac API routes (new): auth, admin, health, environments, plugins, dashboards (helpers, projection, actions, listing), git (config, deps, env, helpers) Clean Release (100%): DTO, facade, policy_engine, stages, repos, preparation, source_isolation, compliance Git services: base, remote_providers Agent module: app, run, middleware, langgraph_setup Core: trace, cleanup, ws_log_handler, timezone, auth (config/oauth/security), matching Reports: normalizer, report_service, type_profiles Notifications: service, providers Also: - .gitignore: add .coverage, *.cover, coverage-* dirs - src/schemas/auth.py: fix AD group DN regex (comma in CN=...) - Remove co-located src/services/__tests__/ (caused pytest module collision)
This commit is contained in:
455
backend/tests/api/test_admin.py
Normal file
455
backend/tests/api/test_admin.py
Normal file
@@ -0,0 +1,455 @@
|
||||
# #region Test.Api.Admin [C:3] [TYPE Module] [SEMANTICS test,admin,api]
|
||||
# @BRIEF Unit tests for admin API routes — users, roles, permissions, AD mappings.
|
||||
# @RELATION BINDS_TO -> [AdminApi]
|
||||
# @TEST_EDGE: user_not_found -> 404
|
||||
# @TEST_EDGE: username_exists -> 400
|
||||
# @TEST_EDGE: role_not_found -> 404
|
||||
# @TEST_EDGE: role_already_exists -> 400
|
||||
|
||||
import os
|
||||
|
||||
# Set env BEFORE any source imports
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
"""Build TestClient with admin router and all deps overridden."""
|
||||
from src.api.routes.admin import router
|
||||
from src.core.database import get_auth_db
|
||||
from src.dependencies import get_current_user, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_user = User(
|
||||
id="admin-1",
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
auth_source="LOCAL",
|
||||
is_active=True,
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="role-1", name="Admin", description="Admin", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_auth_db: lambda: mock_db,
|
||||
get_current_user: lambda: mock_user,
|
||||
has_permission: lambda *args, **kwargs: lambda: None,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── Users ──
|
||||
|
||||
class TestListUsers:
|
||||
"""GET /api/admin/users"""
|
||||
|
||||
def test_list_users_success(self):
|
||||
"""Happy path: list all users returns 200."""
|
||||
from src.core.database import get_auth_db
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user-1"
|
||||
mock_user.username = "alice"
|
||||
mock_user.email = "alice@example.com"
|
||||
mock_user.is_active = True
|
||||
mock_user.auth_source = "LOCAL"
|
||||
mock_user.created_at = __import__("datetime").datetime.now()
|
||||
mock_user.last_login = None
|
||||
mock_user.roles = []
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.all.return_value = [mock_user]
|
||||
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.get("/api/admin/users")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert data[0]["username"] == "alice"
|
||||
|
||||
|
||||
class TestCreateUser:
|
||||
"""POST /api/admin/users"""
|
||||
|
||||
def test_create_user_success(self):
|
||||
"""Happy path: user created with 201."""
|
||||
from src.core.database import get_auth_db
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_user_by_username.return_value = None
|
||||
mock_repo.get_role_by_name.side_effect = lambda name: (
|
||||
MagicMock(id=f"role-{name}", name=name, permissions=[]) if name == "Admin" else None
|
||||
)
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.post("/api/admin/users", json={
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "StrongPass1",
|
||||
"is_active": True,
|
||||
"roles": ["Admin"],
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_create_user_duplicate_username(self):
|
||||
"""Username already exists returns 400."""
|
||||
from src.core.database import get_auth_db
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_user_by_username.return_value = MagicMock()
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.post("/api/admin/users", json={
|
||||
"username": "exists",
|
||||
"email": "dup@example.com",
|
||||
"password": "StrongPass1",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "Username already exists" in resp.text
|
||||
|
||||
def test_create_user_weak_password(self):
|
||||
"""Weak password returns 422."""
|
||||
client = _make_client()
|
||||
resp = client.post("/api/admin/users", json={
|
||||
"username": "weakuser",
|
||||
"email": "weak@example.com",
|
||||
"password": "short",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_user_no_permission(self):
|
||||
"""User without write permission gets 403."""
|
||||
from src.core.database import get_auth_db
|
||||
from src.dependencies import get_current_user, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
from src.api.routes.admin import router
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_auth_db] = lambda: MagicMock()
|
||||
app.dependency_overrides[get_current_user] = lambda: User(
|
||||
id="user-1", username="regular", email="u@x.com", auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(), roles=[]
|
||||
)
|
||||
# Keep has_permission as real — it will use the mock get_current_user
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/admin/users", json={
|
||||
"username": "test",
|
||||
"email": "test@example.com",
|
||||
"password": "StrongPass1",
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestUpdateUser:
|
||||
"""PUT /api/admin/users/{user_id}"""
|
||||
|
||||
def test_update_user_success(self):
|
||||
"""Happy path: user updated and returned."""
|
||||
from src.core.database import get_auth_db
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
existing_user = MagicMock()
|
||||
existing_user.id = "user-1"
|
||||
existing_user.username = "oldname"
|
||||
existing_user.email = "old@example.com"
|
||||
existing_user.is_active = True
|
||||
existing_user.roles = []
|
||||
mock_repo.get_user_by_id.return_value = existing_user
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.put("/api/admin/users/user-1", json={"email": "new@example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert existing_user.email == "new@example.com"
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_update_user_not_found(self):
|
||||
"""Non-existent user returns 404."""
|
||||
from src.core.database import get_auth_db
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_user_by_id.return_value = None
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.put("/api/admin/users/user-999", json={"email": "x@y.com"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteUser:
|
||||
"""DELETE /api/admin/users/{user_id}"""
|
||||
|
||||
def test_delete_user_success(self):
|
||||
"""Happy path: user deleted returns 204."""
|
||||
from src.core.database import get_auth_db
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_user_by_id.return_value = MagicMock(username="testuser")
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.delete("/api/admin/users/user-1")
|
||||
assert resp.status_code == 204
|
||||
mock_session.delete.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_delete_user_not_found(self):
|
||||
"""Non-existent user returns 404."""
|
||||
from src.core.database import get_auth_db
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_user_by_id.return_value = None
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.delete("/api/admin/users/user-999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Roles ──
|
||||
|
||||
class TestListRoles:
|
||||
"""GET /api/admin/roles"""
|
||||
|
||||
def test_list_roles_success(self):
|
||||
"""Happy path: list roles returns 200."""
|
||||
mock_role = MagicMock()
|
||||
mock_role.id = "role-1"
|
||||
mock_role.name = "Admin"
|
||||
mock_role.description = "Administrator"
|
||||
mock_role.permissions = []
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.all.return_value = [mock_role]
|
||||
|
||||
from src.core.database import get_auth_db
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.get("/api/admin/roles")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
|
||||
class TestCreateRole:
|
||||
"""POST /api/admin/roles"""
|
||||
|
||||
def test_create_role_success(self):
|
||||
"""Happy path: role created with 201."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = None
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_permission_by_id.return_value = MagicMock(id="perm-1")
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
from src.core.database import get_auth_db
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.post("/api/admin/roles", json={
|
||||
"name": "Editor",
|
||||
"description": "Can edit",
|
||||
"permissions": ["perm-1"],
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_create_role_duplicate(self):
|
||||
"""Duplicate role name returns 400."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = MagicMock()
|
||||
|
||||
from src.core.database import get_auth_db
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.post("/api/admin/roles", json={
|
||||
"name": "Admin",
|
||||
"description": "Already exists",
|
||||
"permissions": [],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "Role already exists" in resp.text
|
||||
|
||||
|
||||
class TestUpdateRole:
|
||||
"""PUT /api/admin/roles/{role_id}"""
|
||||
|
||||
def test_update_role_success(self):
|
||||
"""Happy path: role updated."""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
existing_role = MagicMock()
|
||||
existing_role.id = "role-1"
|
||||
existing_role.name = "OldName"
|
||||
existing_role.description = "Old desc"
|
||||
existing_role.permissions = []
|
||||
mock_repo.get_role_by_id.return_value = existing_role
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
from src.core.database import get_auth_db
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.put("/api/admin/roles/role-1", json={"name": "NewName", "description": "New desc"})
|
||||
assert resp.status_code == 200
|
||||
assert existing_role.name == "NewName"
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_update_role_not_found(self):
|
||||
"""Non-existent role returns 404."""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_role_by_id.return_value = None
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
from src.core.database import get_auth_db
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.put("/api/admin/roles/role-999", json={"name": "Ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteRole:
|
||||
"""DELETE /api/admin/roles/{role_id}"""
|
||||
|
||||
def test_delete_role_success(self):
|
||||
"""Happy path: role deleted returns 204."""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_role_by_id.return_value = MagicMock()
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
from src.core.database import get_auth_db
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.delete("/api/admin/roles/role-1")
|
||||
assert resp.status_code == 204
|
||||
mock_session.delete.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_delete_role_not_found(self):
|
||||
"""Non-existent role returns 404."""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_role_by_id.return_value = None
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
|
||||
from src.core.database import get_auth_db
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.delete("/api/admin/roles/role-999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Permissions ──
|
||||
|
||||
class TestListPermissions:
|
||||
"""GET /api/admin/permissions"""
|
||||
|
||||
def test_list_permissions_success(self):
|
||||
"""Happy path: returns permissions list."""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_perm = MagicMock()
|
||||
mock_perm.id = "perm-1"
|
||||
mock_perm.resource = "users"
|
||||
mock_perm.action = "read"
|
||||
mock_repo.list_permissions.return_value = [mock_perm]
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo), \
|
||||
patch("src.api.routes.admin.discover_declared_permissions", return_value=[]), \
|
||||
patch("src.api.routes.admin.sync_permission_catalog", return_value=0):
|
||||
from src.core.database import get_auth_db
|
||||
from src.dependencies import get_plugin_loader
|
||||
client = _make_client({
|
||||
get_auth_db: lambda: mock_session,
|
||||
get_plugin_loader: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.get("/api/admin/permissions")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
def test_list_permissions_with_sync(self):
|
||||
"""When new permissions discovered, sync is called."""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_permissions.return_value = []
|
||||
|
||||
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo), \
|
||||
patch("src.api.routes.admin.discover_declared_permissions", return_value=["new:perm"]), \
|
||||
patch("src.api.routes.admin.sync_permission_catalog", return_value=2):
|
||||
from src.core.database import get_auth_db
|
||||
from src.dependencies import get_plugin_loader
|
||||
client = _make_client({
|
||||
get_auth_db: lambda: mock_session,
|
||||
get_plugin_loader: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.get("/api/admin/permissions")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── AD Mappings ──
|
||||
|
||||
class TestListAdMappings:
|
||||
"""GET /api/admin/ad-mappings"""
|
||||
|
||||
def test_list_mappings_success(self):
|
||||
"""Happy path: returns AD mappings."""
|
||||
mock_mapping = MagicMock()
|
||||
mock_mapping.id = "map-1"
|
||||
mock_mapping.ad_group = "DOMAIN\\group1"
|
||||
mock_mapping.role_id = "role-1"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.all.return_value = [mock_mapping]
|
||||
|
||||
from src.core.database import get_auth_db
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.get("/api/admin/ad-mappings")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
|
||||
class TestCreateAdMapping:
|
||||
"""POST /api/admin/ad-mappings"""
|
||||
|
||||
def test_create_mapping_success(self):
|
||||
"""Happy path: AD mapping created."""
|
||||
mock_session = MagicMock()
|
||||
|
||||
from src.core.database import get_auth_db
|
||||
client = _make_client({get_auth_db: lambda: mock_session})
|
||||
resp = client.post("/api/admin/ad-mappings", json={
|
||||
"ad_group": "DOMAIN\\newgroup",
|
||||
"role_id": "role-1",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_create_mapping_invalid_ad_group(self):
|
||||
"""Invalid AD group name returns 422."""
|
||||
client = _make_client()
|
||||
resp = client.post("/api/admin/ad-mappings", json={
|
||||
"ad_group": "invalid group with spaces!!!",
|
||||
"role_id": "role-1",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
# #endregion Test.Api.Admin
|
||||
252
backend/tests/api/test_auth.py
Normal file
252
backend/tests/api/test_auth.py
Normal file
@@ -0,0 +1,252 @@
|
||||
# #region Test.Api.Auth [C:3] [TYPE Module] [SEMANTICS test,auth,api]
|
||||
# @BRIEF Unit tests for auth API routes — login, logout, me, ADFS.
|
||||
# @RELATION BINDS_TO -> [Api.Auth]
|
||||
# @TEST_EDGE: invalid_credentials -> 401
|
||||
# @TEST_EDGE: locked_account -> 429 (rate limited)
|
||||
# @TEST_EDGE: missing_fields -> 422
|
||||
# @TEST_EDGE: already_expired_token -> 200 (idempotent logout)
|
||||
|
||||
import os
|
||||
|
||||
# Set env BEFORE any source imports
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_rate_limiter():
|
||||
"""Ensure rate limiter is safe."""
|
||||
with patch("src.api.auth.rate_limiter") as mock_rl:
|
||||
mock_rl.is_banned.return_value = False
|
||||
mock_rl.record_attempt = MagicMock()
|
||||
mock_rl.record_success = MagicMock()
|
||||
yield
|
||||
|
||||
|
||||
# ── Helper: build TestClient with auth dependencies overridden ──
|
||||
|
||||
def _make_client() -> TestClient:
|
||||
"""Build a TestClient for the auth router with all dependencies overridden."""
|
||||
from src.api.auth import router
|
||||
from src.core.database import get_auth_db
|
||||
from src.dependencies import get_current_user
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_user = User(
|
||||
id="user-1",
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="role-1", name="Admin", description="Admin role", permissions=[])],
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_auth_db] = lambda: mock_db
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── Tests ──
|
||||
|
||||
class TestLogin:
|
||||
"""POST /api/auth/login"""
|
||||
|
||||
def test_login_success(self):
|
||||
"""Happy path: valid credentials return 200 + Token."""
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "testuser"
|
||||
mock_token = MagicMock()
|
||||
mock_token.access_token = "abc.def.ghi"
|
||||
mock_token.token_type = "bearer"
|
||||
|
||||
with patch("src.api.auth.AuthService") as MockAuth:
|
||||
auth_instance = MockAuth.return_value
|
||||
auth_instance.authenticate_user.return_value = mock_user
|
||||
auth_instance.create_session.return_value = mock_token
|
||||
|
||||
client = _make_client()
|
||||
resp = client.post("/api/auth/login", data={"username": "testuser", "password": "secret123"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["access_token"] == "abc.def.ghi"
|
||||
assert resp.json()["token_type"] == "bearer"
|
||||
|
||||
def test_login_invalid_credentials(self):
|
||||
"""Invalid credentials return 401."""
|
||||
with patch("src.api.auth.AuthService") as MockAuth:
|
||||
auth_instance = MockAuth.return_value
|
||||
auth_instance.authenticate_user.return_value = None
|
||||
|
||||
client = _make_client()
|
||||
resp = client.post("/api/auth/login", data={"username": "bad", "password": "wrong"})
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect username or password" in resp.text
|
||||
|
||||
def test_login_rate_limited(self):
|
||||
"""Banned IP returns 429."""
|
||||
with patch("src.api.auth.rate_limiter") as mock_rl:
|
||||
mock_rl.is_banned.return_value = True
|
||||
client = _make_client()
|
||||
resp = client.post("/api/auth/login", data={"username": "test", "password": "test"})
|
||||
|
||||
assert resp.status_code == 429
|
||||
assert "Too many login attempts" in resp.text
|
||||
|
||||
def test_login_missing_fields(self):
|
||||
"""Missing form data returns 422."""
|
||||
client = _make_client()
|
||||
resp = client.post("/api/auth/login", data={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestMe:
|
||||
"""GET /api/auth/me"""
|
||||
|
||||
def test_me_authenticated(self):
|
||||
"""Authenticated user returns profile."""
|
||||
client = _make_client()
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "testuser"
|
||||
assert data["email"] == "test@example.com"
|
||||
|
||||
def test_me_unauthenticated(self):
|
||||
"""Unauthenticated request returns 401."""
|
||||
from src.core.database import get_auth_db
|
||||
from src.dependencies import get_current_user
|
||||
|
||||
app = FastAPI()
|
||||
from src.api.auth import router
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_current_user] = lambda: (_ for _ in ()).throw(
|
||||
HTTPException(status_code=401, detail="Not authenticated")
|
||||
)
|
||||
app.dependency_overrides[get_auth_db] = lambda: MagicMock()
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestLogout:
|
||||
"""POST /api/auth/logout"""
|
||||
|
||||
def test_logout_success(self):
|
||||
"""Valid token blacklisted, returns 200."""
|
||||
with patch("src.api.auth.blacklist_token") as mock_blacklist:
|
||||
client = _make_client()
|
||||
resp = client.post(
|
||||
"/api/auth/logout",
|
||||
headers={"Authorization": "Bearer some.jwt.token"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Successfully logged out"
|
||||
mock_blacklist.assert_called_once()
|
||||
|
||||
def test_logout_no_token_header(self):
|
||||
"""No Authorization header still succeeds (no token to blacklist)."""
|
||||
with patch("src.api.auth.blacklist_token") as mock_blacklist:
|
||||
client = _make_client()
|
||||
resp = client.post("/api/auth/logout")
|
||||
assert resp.status_code == 200
|
||||
mock_blacklist.assert_not_called()
|
||||
|
||||
def test_logout_expired_token(self):
|
||||
"""Expired token — still returns 200 (idempotent)."""
|
||||
with patch("src.api.auth.blacklist_token") as mock_blacklist:
|
||||
client = _make_client()
|
||||
resp = client.post(
|
||||
"/api/auth/logout",
|
||||
headers={"Authorization": "Bearer expired.token.here"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_blacklist.assert_called_once()
|
||||
|
||||
|
||||
class TestLoginAdfs:
|
||||
"""GET /api/auth/login/adfs"""
|
||||
|
||||
def test_adfs_not_configured(self):
|
||||
"""ADFS not configured returns 503."""
|
||||
with patch("src.api.auth.is_adfs_configured", return_value=False):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/auth/login/adfs")
|
||||
assert resp.status_code == 503
|
||||
assert "ADFS is not configured" in resp.text
|
||||
|
||||
def test_adfs_redirect(self):
|
||||
"""ADFS configured redirects to provider."""
|
||||
mock_oauth = MagicMock()
|
||||
mock_oauth.adfs.authorize_redirect = AsyncMock(return_value=None)
|
||||
|
||||
with patch("src.api.auth.is_adfs_configured", return_value=True), \
|
||||
patch("src.api.auth.oauth", mock_oauth):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/auth/login/adfs")
|
||||
assert resp.status_code in (200, 307)
|
||||
|
||||
|
||||
class TestCallbackAdfs:
|
||||
"""GET /api/auth/callback/adfs"""
|
||||
|
||||
def test_callback_not_configured(self):
|
||||
"""ADFS not configured returns 503."""
|
||||
with patch("src.api.auth.is_adfs_configured", return_value=False):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/auth/callback/adfs")
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_callback_no_userinfo(self):
|
||||
"""ADFS token without userinfo returns 400."""
|
||||
mock_oauth = MagicMock()
|
||||
mock_oauth.adfs.authorize_access_token = AsyncMock(return_value={})
|
||||
|
||||
with patch("src.api.auth.is_adfs_configured", return_value=True), \
|
||||
patch("src.api.auth.oauth", mock_oauth):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/auth/callback/adfs")
|
||||
assert resp.status_code == 400
|
||||
assert "Failed to retrieve user info" in resp.text
|
||||
|
||||
def test_callback_success(self):
|
||||
"""ADFS callback provisions user and returns token."""
|
||||
mock_token = MagicMock()
|
||||
mock_token.access_token = "adfs.token.xyz"
|
||||
mock_token.token_type = "bearer"
|
||||
mock_user_info = {"sub": "adfs-user", "email": "adfs@example.com"}
|
||||
|
||||
mock_oauth = MagicMock()
|
||||
mock_oauth.adfs.authorize_access_token = AsyncMock(
|
||||
return_value={"userinfo": mock_user_info}
|
||||
)
|
||||
|
||||
with patch("src.api.auth.is_adfs_configured", return_value=True), \
|
||||
patch("src.api.auth.oauth", mock_oauth), \
|
||||
patch("src.api.auth.AuthService") as MockAuth:
|
||||
auth_instance = MockAuth.return_value
|
||||
auth_instance.provision_adfs_user.return_value = MagicMock()
|
||||
auth_instance.create_session.return_value = mock_token
|
||||
|
||||
client = _make_client()
|
||||
resp = client.get("/api/auth/callback/adfs")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["access_token"] == "adfs.token.xyz"
|
||||
# #endregion Test.Api.Auth
|
||||
299
backend/tests/api/test_dashboard_action_routes.py
Normal file
299
backend/tests/api/test_dashboard_action_routes.py
Normal file
@@ -0,0 +1,299 @@
|
||||
# #region Test.Api.DashboardActionRoutes [C:3] [TYPE Module] [SEMANTICS test,dashboard,action,migration,backup]
|
||||
# @BRIEF Unit tests for dashboard action routes — migrate and backup.
|
||||
# @RELATION BINDS_TO -> [DashboardActionRoutes]
|
||||
# @TEST_EDGE: empty_dashboard_ids -> 400
|
||||
# @TEST_EDGE: source_env_not_found -> 404
|
||||
# @TEST_EDGE: target_env_not_found -> 404
|
||||
# @TEST_EDGE: env_not_found_backup -> 404
|
||||
# @TEST_EDGE: task_creation_fail -> 503
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.dashboards._action_routes import router
|
||||
from src.dependencies import get_current_user, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="admin-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── migrate_dashboards ──
|
||||
|
||||
class TestMigrateDashboards:
|
||||
"""POST /api/dashboards/migrate"""
|
||||
|
||||
def _make_env(self, id: str):
|
||||
env = MagicMock()
|
||||
env.id = id
|
||||
return env
|
||||
|
||||
def test_migrate_success(self):
|
||||
"""Happy path: migration task created returns 200 with task_id."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [
|
||||
self._make_env("src-1"),
|
||||
self._make_env("tgt-1"),
|
||||
]
|
||||
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-123"
|
||||
mock_task_manager = AsyncMock()
|
||||
mock_task_manager.create_task.return_value = mock_task
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: mock_task_manager,
|
||||
})
|
||||
resp = client.post("/api/dashboards/migrate", json={
|
||||
"source_env_id": "src-1",
|
||||
"target_env_id": "tgt-1",
|
||||
"dashboard_ids": [1, 2, 3],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["task_id"] == "task-123"
|
||||
mock_task_manager.create_task.assert_called_once()
|
||||
|
||||
def test_migrate_empty_dashboard_ids(self):
|
||||
"""Empty dashboard_ids returns 400."""
|
||||
mock_config = MagicMock()
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.post("/api/dashboards/migrate", json={
|
||||
"source_env_id": "src-1",
|
||||
"target_env_id": "tgt-1",
|
||||
"dashboard_ids": [],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "At least one dashboard ID" in resp.text
|
||||
|
||||
def test_migrate_source_not_found(self):
|
||||
"""Non-existent source env returns 404."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [self._make_env("tgt-1")]
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.post("/api/dashboards/migrate", json={
|
||||
"source_env_id": "src-missing",
|
||||
"target_env_id": "tgt-1",
|
||||
"dashboard_ids": [1],
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
assert "Source environment not found" in resp.text
|
||||
|
||||
def test_migrate_target_not_found(self):
|
||||
"""Non-existent target env returns 404."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [self._make_env("src-1")]
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.post("/api/dashboards/migrate", json={
|
||||
"source_env_id": "src-1",
|
||||
"target_env_id": "tgt-missing",
|
||||
"dashboard_ids": [1],
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
assert "Target environment not found" in resp.text
|
||||
|
||||
def test_migrate_task_creation_fail(self):
|
||||
"""Task creation failure returns 503."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [
|
||||
self._make_env("src-1"),
|
||||
self._make_env("tgt-1"),
|
||||
]
|
||||
mock_task_manager = AsyncMock()
|
||||
mock_task_manager.create_task.side_effect = Exception("DB down")
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: mock_task_manager,
|
||||
})
|
||||
resp = client.post("/api/dashboards/migrate", json={
|
||||
"source_env_id": "src-1",
|
||||
"target_env_id": "tgt-1",
|
||||
"dashboard_ids": [1],
|
||||
})
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_migrate_with_db_mappings(self):
|
||||
"""Migration with replace_db_config and db_mappings."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [
|
||||
self._make_env("src-1"),
|
||||
self._make_env("tgt-1"),
|
||||
]
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-dbm"
|
||||
mock_task_manager = AsyncMock()
|
||||
mock_task_manager.create_task.return_value = mock_task
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: mock_task_manager,
|
||||
})
|
||||
resp = client.post("/api/dashboards/migrate", json={
|
||||
"source_env_id": "src-1",
|
||||
"target_env_id": "tgt-1",
|
||||
"dashboard_ids": [1],
|
||||
"replace_db_config": True,
|
||||
"db_mappings": {"old_db": "new_db"},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── backup_dashboards ──
|
||||
|
||||
class TestBackupDashboards:
|
||||
"""POST /api/dashboards/backup"""
|
||||
|
||||
def test_backup_success(self):
|
||||
"""Happy path: backup task created."""
|
||||
env = MagicMock()
|
||||
env.id = "env-1"
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-backup-1"
|
||||
mock_task_manager = AsyncMock()
|
||||
mock_task_manager.create_task.return_value = mock_task
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: mock_task_manager,
|
||||
})
|
||||
resp = client.post("/api/dashboards/backup", json={
|
||||
"env_id": "env-1",
|
||||
"dashboard_ids": [10, 20],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["task_id"] == "task-backup-1"
|
||||
|
||||
def test_backup_empty_dashboard_ids(self):
|
||||
"""Empty dashboard_ids returns 400."""
|
||||
mock_config = MagicMock()
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.post("/api/dashboards/backup", json={
|
||||
"env_id": "env-1",
|
||||
"dashboard_ids": [],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "At least one dashboard ID" in resp.text
|
||||
|
||||
def test_backup_env_not_found(self):
|
||||
"""Non-existent env returns 404."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.post("/api/dashboards/backup", json={
|
||||
"env_id": "env-ghost",
|
||||
"dashboard_ids": [1],
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
assert "Environment not found" in resp.text
|
||||
|
||||
def test_backup_with_schedule(self):
|
||||
"""Backup with schedule."""
|
||||
env = MagicMock()
|
||||
env.id = "env-1"
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-sched"
|
||||
mock_task_manager = AsyncMock()
|
||||
mock_task_manager.create_task.return_value = mock_task
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: mock_task_manager,
|
||||
})
|
||||
resp = client.post("/api/dashboards/backup", json={
|
||||
"env_id": "env-1",
|
||||
"dashboard_ids": [1, 2],
|
||||
"schedule": "0 0 * * *",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_backup_task_creation_fail(self):
|
||||
"""Task creation failure returns 503."""
|
||||
env = MagicMock()
|
||||
env.id = "env-1"
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
mock_task_manager = AsyncMock()
|
||||
mock_task_manager.create_task.side_effect = Exception("Queue full")
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: mock_task_manager,
|
||||
})
|
||||
resp = client.post("/api/dashboards/backup", json={
|
||||
"env_id": "env-1",
|
||||
"dashboard_ids": [1],
|
||||
})
|
||||
assert resp.status_code == 503
|
||||
# #endregion Test.Api.DashboardActionRoutes
|
||||
193
backend/tests/api/test_dashboard_helpers.py
Normal file
193
backend/tests/api/test_dashboard_helpers.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# #region Test.Api.DashboardHelpers [C:3] [TYPE Module] [SEMANTICS test,dashboard,helpers]
|
||||
# @BRIEF Unit tests for dashboard helper functions.
|
||||
# @RELATION BINDS_TO -> [DashboardHelpers]
|
||||
# @TEST_EDGE: empty_ref -> 404
|
||||
# @TEST_EDGE: slug_resolution -> int | None
|
||||
# @TEST_EDGE: numeric_ref_fallback -> int
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
# ── Deprecated sync functions ──
|
||||
|
||||
class TestDeprecatedSyncFunctions:
|
||||
"""_find_dashboard_id_by_slug and _resolve_dashboard_id_from_ref (sync) raise RuntimeError."""
|
||||
|
||||
def test_find_dashboard_id_by_slug_deprecated(self):
|
||||
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug
|
||||
with pytest.raises(RuntimeError, match="deprecated"):
|
||||
_find_dashboard_id_by_slug(MagicMock(), "test-slug")
|
||||
|
||||
def test_resolve_dashboard_id_from_ref_multiple_defs(self):
|
||||
"""The module has two defs of _resolve_dashboard_id_from_ref. The second wins.
|
||||
It should raise HTTPException(404) for unknown refs, not RuntimeError."""
|
||||
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref
|
||||
from fastapi import HTTPException as HE
|
||||
|
||||
client = MagicMock()
|
||||
client.get_dashboards_page.return_value = (0, [])
|
||||
|
||||
with pytest.raises(HE) as exc:
|
||||
_resolve_dashboard_id_from_ref("unknown-ref", client)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ── _find_dashboard_id_by_slug_async ──
|
||||
|
||||
class TestFindDashboardIdBySlugAsync:
|
||||
"""_find_dashboard_id_by_slug_async"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_found_by_slug(self):
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (1, [{"id": 42}])
|
||||
|
||||
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug_async
|
||||
result = await _find_dashboard_id_by_slug_async(client, "my-slug")
|
||||
assert result == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_found(self):
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (0, [])
|
||||
|
||||
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug_async
|
||||
result = await _find_dashboard_id_by_slug_async(client, "ghost-slug")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_then_second_query(self):
|
||||
client = AsyncMock()
|
||||
# First query raises, second succeeds
|
||||
client.get_dashboards_page.side_effect = [
|
||||
Exception("First query failed"),
|
||||
(1, [{"id": 99}]),
|
||||
]
|
||||
|
||||
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug_async
|
||||
result = await _find_dashboard_id_by_slug_async(client, "retry-slug")
|
||||
assert result == 99
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_queries_fail(self):
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.side_effect = Exception("All failed")
|
||||
|
||||
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug_async
|
||||
result = await _find_dashboard_id_by_slug_async(client, "fail-slug")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── _resolve_dashboard_id_from_ref_async ──
|
||||
|
||||
class TestResolveDashboardIdFromRefAsync:
|
||||
"""_resolve_dashboard_id_from_ref_async"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_numeric_ref(self):
|
||||
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
|
||||
result = await _resolve_dashboard_id_from_ref_async("123", AsyncMock())
|
||||
assert result == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_ref(self):
|
||||
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (1, [{"id": 42}])
|
||||
result = await _resolve_dashboard_id_from_ref_async("my-dash", client)
|
||||
assert result == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_ref(self):
|
||||
from fastapi import HTTPException
|
||||
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref_async("", AsyncMock())
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_not_found(self):
|
||||
from fastapi import HTTPException
|
||||
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (0, [])
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref_async("ghost", client)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_ref(self):
|
||||
from fastapi import HTTPException
|
||||
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref_async(None, AsyncMock()) # type: ignore
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ── _normalize_filter_values ──
|
||||
|
||||
class TestNormalizeFilterValues:
|
||||
"""_normalize_filter_values"""
|
||||
|
||||
def test_normalize_returns_lowercase(self):
|
||||
from src.api.routes.dashboards._helpers import _normalize_filter_values
|
||||
assert _normalize_filter_values(["HELLO", "World"]) == ["hello", "world"]
|
||||
|
||||
def test_normalize_empty_list(self):
|
||||
from src.api.routes.dashboards._helpers import _normalize_filter_values
|
||||
assert _normalize_filter_values([]) == []
|
||||
|
||||
def test_normalize_none(self):
|
||||
from src.api.routes.dashboards._helpers import _normalize_filter_values
|
||||
assert _normalize_filter_values(None) == []
|
||||
|
||||
def test_normalize_removes_empty_strings(self):
|
||||
from src.api.routes.dashboards._helpers import _normalize_filter_values
|
||||
assert _normalize_filter_values(["a", "", " ", "b"]) == ["a", "b"]
|
||||
|
||||
def test_normalize_strips_whitespace(self):
|
||||
from src.api.routes.dashboards._helpers import _normalize_filter_values
|
||||
assert _normalize_filter_values([" Foo Bar "]) == ["foo bar"]
|
||||
|
||||
|
||||
# ── _dashboard_git_filter_value ──
|
||||
|
||||
class TestDashboardGitFilterValue:
|
||||
"""_dashboard_git_filter_value"""
|
||||
|
||||
def test_no_repo(self):
|
||||
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
|
||||
assert _dashboard_git_filter_value({"git_status": {"has_repo": False}}) == "no_repo"
|
||||
|
||||
def test_no_repo_status(self):
|
||||
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
|
||||
assert _dashboard_git_filter_value({"git_status": {"sync_status": "NO_REPO"}}) == "no_repo"
|
||||
|
||||
def test_diff(self):
|
||||
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
|
||||
assert _dashboard_git_filter_value({"git_status": {"sync_status": "DIFF", "has_repo": True}}) == "diff"
|
||||
|
||||
def test_ok(self):
|
||||
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
|
||||
assert _dashboard_git_filter_value({"git_status": {"sync_status": "OK", "has_repo": True}}) == "ok"
|
||||
|
||||
def test_error(self):
|
||||
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
|
||||
assert _dashboard_git_filter_value({"git_status": {"sync_status": "ERROR", "has_repo": True}}) == "error"
|
||||
|
||||
def test_missing_git_status(self):
|
||||
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
|
||||
assert _dashboard_git_filter_value({}) == "pending"
|
||||
|
||||
def test_none_git_status(self):
|
||||
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
|
||||
assert _dashboard_git_filter_value({"git_status": None}) == "pending"
|
||||
# #endregion Test.Api.DashboardHelpers
|
||||
255
backend/tests/api/test_dashboard_listing_routes.py
Normal file
255
backend/tests/api/test_dashboard_listing_routes.py
Normal file
@@ -0,0 +1,255 @@
|
||||
# #region Test.Api.DashboardListingRoutes [C:3] [TYPE Module] [SEMANTICS test,dashboard,listing,api]
|
||||
# @BRIEF Unit tests for dashboard listing route — get_dashboards.
|
||||
# @RELATION BINDS_TO -> [DashboardListingRoutes]
|
||||
# @TEST_EDGE: page_less_than_one -> 400
|
||||
# @TEST_EDGE: page_size_invalid -> 400
|
||||
# @TEST_EDGE: environment_not_found -> 404
|
||||
# @TEST_EDGE: superset_fail -> 503
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
def _make_mock_env(id: str = "env-1", name: str = "TestEnv"):
|
||||
env = MagicMock()
|
||||
env.id = id
|
||||
env.name = name
|
||||
env.url = "https://superset.example.com"
|
||||
return env
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.dashboards._listing_routes import router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import (
|
||||
get_config_manager,
|
||||
get_current_user,
|
||||
get_resource_service,
|
||||
get_task_manager,
|
||||
has_permission,
|
||||
)
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="user-1",
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="role-1", name="Admin", description="Admin", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_db: lambda: MagicMock(),
|
||||
get_config_manager: lambda: MagicMock(),
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
get_resource_service: lambda: AsyncMock(),
|
||||
get_current_user: lambda: mock_user,
|
||||
has_permission: lambda *a, **kw: lambda: None,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestGetDashboards:
|
||||
"""GET /api/dashboards"""
|
||||
|
||||
@pytest.fixture
|
||||
def base_mocks(self):
|
||||
env = _make_mock_env("env-1")
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
mock_task_manager = MagicMock()
|
||||
mock_task_manager.get_all_tasks.return_value = []
|
||||
|
||||
mock_rs = AsyncMock()
|
||||
mock_rs.get_dashboards_page_with_status.return_value = {
|
||||
"dashboards": [
|
||||
{"id": 1, "title": "Main Dashboard", "slug": "main", "owners": []},
|
||||
],
|
||||
"total": 1,
|
||||
"total_pages": 1,
|
||||
}
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
from src.dependencies import get_config_manager, get_db, get_resource_service, get_task_manager
|
||||
return {
|
||||
"config_manager": mock_config,
|
||||
"task_manager": mock_task_manager,
|
||||
"resource_service": mock_rs,
|
||||
"db": mock_db,
|
||||
}
|
||||
|
||||
def test_get_dashboards_success(self, base_mocks):
|
||||
"""Happy path: returns paginated dashboards."""
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["dashboards"]) == 1
|
||||
assert data["dashboards"][0]["title"] == "Main Dashboard"
|
||||
|
||||
def test_get_dashboards_page_less_than_one(self, base_mocks):
|
||||
"""Page < 1 returns 400."""
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&page=0")
|
||||
assert resp.status_code == 400
|
||||
assert "Page must be >= 1" in resp.text
|
||||
|
||||
def test_get_dashboards_page_size_invalid(self, base_mocks):
|
||||
"""page_size < 1 returns 400."""
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&page_size=0")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_dashboards_page_size_too_large(self, base_mocks):
|
||||
"""page_size > 100 returns 400."""
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&page_size=101")
|
||||
assert resp.status_code == 400
|
||||
assert "Page size must be between 1 and 100" in resp.text
|
||||
|
||||
def test_get_dashboards_env_not_found(self, base_mocks):
|
||||
"""Non-existent env returns 404."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
mocks = {**base_mocks, "config_manager": mock_config}
|
||||
client = _make_client(mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-ghost")
|
||||
assert resp.status_code == 404
|
||||
assert "Environment not found" in resp.text
|
||||
|
||||
def test_get_dashboards_with_search(self, base_mocks):
|
||||
"""Search filter applied via fallback path."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
|
||||
mock_rs.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "Revenue Dashboard", "slug": "revenue", "owners": []},
|
||||
{"id": 2, "title": "Sales Report", "slug": "sales", "owners": []},
|
||||
]
|
||||
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&search=revenue")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["dashboards"][0]["title"] == "Revenue Dashboard"
|
||||
|
||||
def test_get_dashboards_full_scan_with_filters(self, base_mocks):
|
||||
"""Column filters trigger full scan path."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
|
||||
mock_rs.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "Main Dashboard", "slug": "main", "owners": [],
|
||||
"git_status": {"sync_status": "OK", "has_repo": True}},
|
||||
]
|
||||
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&filter_git_status=ok")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_get_dashboards_internal_exception(self, base_mocks):
|
||||
"""Internal error returns 503."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("Critical failure")
|
||||
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1")
|
||||
assert resp.status_code == 503
|
||||
assert "Failed to fetch dashboards" in resp.text
|
||||
|
||||
def test_get_dashboards_slug_filter_only(self, base_mocks):
|
||||
"""Slug-only filter."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
|
||||
mock_rs.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "Main", "slug": "", "owners": []},
|
||||
{"id": 2, "title": "With Slug", "slug": "with-slug", "owners": []},
|
||||
]
|
||||
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&apply_profile_default=false&override_show_all=true")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_get_dashboards_title_filters(self, base_mocks):
|
||||
"""Title column filter applied."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
|
||||
mock_rs.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "Revenue Dashboard", "slug": "revenue", "owners": []},
|
||||
{"id": 2, "title": "Sales Dashboard", "slug": "sales", "owners": []},
|
||||
]
|
||||
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&filter_title=revenue+dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_get_dashboards_actor_filter(self, base_mocks):
|
||||
"""Actor column filter applied."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
|
||||
mock_rs.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "Main", "slug": "main", "owners": [], "last_modified": "2024-01-01"},
|
||||
]
|
||||
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&filter_actor=alice")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_get_dashboards_changed_on_filter(self, base_mocks):
|
||||
"""Changed-on filter applied."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
|
||||
mock_rs.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "Main", "slug": "main", "owners": [], "last_modified": "2024-01-01T12:00:00"},
|
||||
]
|
||||
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&filter_changed_on=2024-01-01")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_get_dashboards_page_fallback_to_full_scan(self, base_mocks):
|
||||
"""When page-based fetch fails, fallback to full scan."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
|
||||
mock_rs.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "First", "slug": "first", "owners": []},
|
||||
{"id": 2, "title": "Second", "slug": "second", "owners": []},
|
||||
]
|
||||
|
||||
client = _make_client(base_mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&page=1&page_size=1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert data["total_pages"] == 2
|
||||
assert len(data["dashboards"]) == 1
|
||||
# #endregion Test.Api.DashboardListingRoutes
|
||||
278
backend/tests/api/test_dashboard_projection.py
Normal file
278
backend/tests/api/test_dashboard_projection.py
Normal file
@@ -0,0 +1,278 @@
|
||||
# #region Test.Api.DashboardProjection [C:3] [TYPE Module] [SEMANTICS test,dashboard,projection]
|
||||
# @BRIEF Unit tests for dashboard projection/profile helpers.
|
||||
# @RELATION BINDS_TO -> [DashboardProjection]
|
||||
# @TEST_EDGE: owner_normalization
|
||||
# @TEST_EDGE: profile_filter_binding
|
||||
# @TEST_EDGE: task_matching
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
# ── _normalize_actor_alias_token ──
|
||||
|
||||
class TestNormalizeActorAliasToken:
|
||||
def test_none_value(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_actor_alias_token
|
||||
assert _normalize_actor_alias_token(None) is None
|
||||
|
||||
def test_empty_string(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_actor_alias_token
|
||||
assert _normalize_actor_alias_token("") is None
|
||||
|
||||
def test_whitespace(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_actor_alias_token
|
||||
assert _normalize_actor_alias_token(" ") is None
|
||||
|
||||
def test_normalized(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_actor_alias_token
|
||||
assert _normalize_actor_alias_token(" Alice ") == "alice"
|
||||
|
||||
|
||||
# ── _normalize_owner_display_token ──
|
||||
|
||||
class TestNormalizeOwnerDisplayToken:
|
||||
def test_none(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_owner_display_token
|
||||
assert _normalize_owner_display_token(None) is None
|
||||
|
||||
def test_dict_with_username(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_owner_display_token
|
||||
assert _normalize_owner_display_token({"username": "Alice"}) == "Alice"
|
||||
|
||||
def test_dict_with_full_name(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_owner_display_token
|
||||
assert _normalize_owner_display_token({"full_name": "Alice Smith"}) == "Alice Smith"
|
||||
|
||||
def test_dict_empty(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_owner_display_token
|
||||
assert _normalize_owner_display_token({}) is None
|
||||
|
||||
def test_string(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_owner_display_token
|
||||
assert _normalize_owner_display_token("Alice") == "Alice"
|
||||
|
||||
def test_int(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_owner_display_token
|
||||
assert _normalize_owner_display_token(42) is None
|
||||
|
||||
|
||||
# ── _normalize_dashboard_owner_values ──
|
||||
|
||||
class TestNormalizeDashboardOwnerValues:
|
||||
def test_none(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_dashboard_owner_values
|
||||
assert _normalize_dashboard_owner_values(None) is None
|
||||
|
||||
def test_list_of_dicts(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_dashboard_owner_values
|
||||
result = _normalize_dashboard_owner_values([
|
||||
{"username": "Alice"},
|
||||
{"username": "Bob"},
|
||||
])
|
||||
assert result == ["Alice", "Bob"]
|
||||
|
||||
def test_single_dict(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_dashboard_owner_values
|
||||
result = _normalize_dashboard_owner_values({"username": "Charlie"})
|
||||
assert result == ["Charlie"]
|
||||
|
||||
def test_duplicates_removed(self):
|
||||
from src.api.routes.dashboards._projection import _normalize_dashboard_owner_values
|
||||
result = _normalize_dashboard_owner_values([
|
||||
{"username": "Alice"},
|
||||
{"username": "Alice"},
|
||||
])
|
||||
assert result == ["Alice"]
|
||||
|
||||
|
||||
# ── _project_dashboard_response_items ──
|
||||
|
||||
class TestProjectDashboardResponseItems:
|
||||
def test_owners_normalized(self):
|
||||
from src.api.routes.dashboards._projection import _project_dashboard_response_items
|
||||
dashboards = [
|
||||
{"id": 1, "title": "Main", "owners": [{"username": "Alice"}]},
|
||||
]
|
||||
result = _project_dashboard_response_items(dashboards)
|
||||
assert result[0]["owners"] == ["Alice"]
|
||||
|
||||
def test_empty_list(self):
|
||||
from src.api.routes.dashboards._projection import _project_dashboard_response_items
|
||||
assert _project_dashboard_response_items([]) == []
|
||||
|
||||
|
||||
# ── _get_profile_filter_binding ──
|
||||
|
||||
class TestGetProfileFilterBinding:
|
||||
def test_uses_get_dashboard_filter_binding(self):
|
||||
from src.api.routes.dashboards._projection import _get_profile_filter_binding
|
||||
profile_service = MagicMock()
|
||||
profile_service.get_dashboard_filter_binding.return_value = {
|
||||
"superset_username": "alice",
|
||||
"superset_username_normalized": "alice",
|
||||
"show_only_my_dashboards": True,
|
||||
"show_only_slug_dashboards": False,
|
||||
}
|
||||
result = _get_profile_filter_binding(profile_service, MagicMock())
|
||||
assert result["superset_username"] == "alice"
|
||||
assert result["show_only_my_dashboards"] is True
|
||||
|
||||
def test_uses_get_my_preference_fallback(self):
|
||||
from src.api.routes.dashboards._projection import _get_profile_filter_binding
|
||||
profile_service = MagicMock()
|
||||
profile_service.get_dashboard_filter_binding = None
|
||||
pref = MagicMock()
|
||||
pref.preference.superset_username = "bob"
|
||||
pref.preference.superset_username_normalized = "bob"
|
||||
pref.preference.show_only_my_dashboards = True
|
||||
pref.preference.show_only_slug_dashboards = False
|
||||
profile_service.get_my_preference.return_value = pref
|
||||
|
||||
result = _get_profile_filter_binding(profile_service, MagicMock())
|
||||
assert result["superset_username"] == "bob"
|
||||
|
||||
def test_no_methods_returns_defaults(self):
|
||||
from src.api.routes.dashboards._projection import _get_profile_filter_binding
|
||||
profile_service = MagicMock(spec=[]) # no relevant methods
|
||||
result = _get_profile_filter_binding(profile_service, MagicMock())
|
||||
assert result["superset_username"] is None
|
||||
assert result["show_only_my_dashboards"] is False
|
||||
|
||||
|
||||
# ── _resolve_profile_actor_aliases ──
|
||||
|
||||
class TestResolveProfileActorAliases:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_username(self):
|
||||
from src.api.routes.dashboards._projection import _resolve_profile_actor_aliases
|
||||
result = await _resolve_profile_actor_aliases(MagicMock(), "")
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_success(self):
|
||||
from src.api.routes.dashboards._projection import _resolve_profile_actor_aliases
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.get_users_page.return_value = {
|
||||
"items": [
|
||||
{"username": "alice", "display_name": "Alice Smith"},
|
||||
]
|
||||
}
|
||||
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "env-1"
|
||||
|
||||
with patch("src.api.routes.dashboards._projection.AsyncSupersetClient") as MockClient, \
|
||||
patch("src.api.routes.dashboards._projection.SupersetAccountLookupAdapter") as MockAdapter:
|
||||
|
||||
MockAdapter.return_value = mock_adapter
|
||||
|
||||
result = await _resolve_profile_actor_aliases(mock_env, "alice")
|
||||
assert "alice" in result
|
||||
assert "alice smith" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_exception(self):
|
||||
from src.api.routes.dashboards._projection import _resolve_profile_actor_aliases
|
||||
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "env-1"
|
||||
|
||||
with patch("src.api.routes.dashboards._projection.AsyncSupersetClient") as MockClient, \
|
||||
patch("src.api.routes.dashboards._projection.SupersetAccountLookupAdapter") as MockAdapter:
|
||||
|
||||
MockAdapter.side_effect = Exception("Connection error")
|
||||
|
||||
result = await _resolve_profile_actor_aliases(mock_env, "alice")
|
||||
assert result == ["alice"]
|
||||
|
||||
|
||||
# ── _matches_dashboard_actor_aliases ──
|
||||
|
||||
class TestMatchesDashboardActorAliases:
|
||||
def test_matches(self):
|
||||
from src.api.routes.dashboards._projection import _matches_dashboard_actor_aliases
|
||||
profile_service = MagicMock()
|
||||
profile_service.matches_dashboard_actor.return_value = True
|
||||
assert _matches_dashboard_actor_aliases(profile_service, ["alice"], [], "bob") is True
|
||||
|
||||
def test_no_match(self):
|
||||
from src.api.routes.dashboards._projection import _matches_dashboard_actor_aliases
|
||||
profile_service = MagicMock()
|
||||
profile_service.matches_dashboard_actor.return_value = False
|
||||
assert _matches_dashboard_actor_aliases(profile_service, ["alice"], [], None) is False
|
||||
|
||||
def test_first_match_short_circuits(self):
|
||||
from src.api.routes.dashboards._projection import _matches_dashboard_actor_aliases
|
||||
profile_service = MagicMock()
|
||||
profile_service.matches_dashboard_actor.side_effect = [False, True]
|
||||
assert _matches_dashboard_actor_aliases(profile_service, ["alice", "bob"], [], None) is True
|
||||
assert profile_service.matches_dashboard_actor.call_count == 2
|
||||
|
||||
|
||||
# ── _task_matches_dashboard ──
|
||||
|
||||
class TestTaskMatchesDashboard:
|
||||
def test_llm_validation_matches(self):
|
||||
from src.api.routes.dashboards._projection import _task_matches_dashboard
|
||||
task = MagicMock()
|
||||
task.plugin_id = "llm_dashboard_validation"
|
||||
task.params = {"dashboard_id": 42, "environment_id": "env-1"}
|
||||
assert _task_matches_dashboard(task, 42, "env-1") is True
|
||||
|
||||
def test_llm_validation_wrong_dashboard(self):
|
||||
from src.api.routes.dashboards._projection import _task_matches_dashboard
|
||||
task = MagicMock()
|
||||
task.plugin_id = "llm_dashboard_validation"
|
||||
task.params = {"dashboard_id": 99}
|
||||
assert _task_matches_dashboard(task, 42, None) is False
|
||||
|
||||
def test_llm_validation_no_env(self):
|
||||
from src.api.routes.dashboards._projection import _task_matches_dashboard
|
||||
task = MagicMock()
|
||||
task.plugin_id = "llm_dashboard_validation"
|
||||
task.params = {"dashboard_id": 42}
|
||||
assert _task_matches_dashboard(task, 42, None) is True
|
||||
|
||||
def test_backup_matches(self):
|
||||
from src.api.routes.dashboards._projection import _task_matches_dashboard
|
||||
task = MagicMock()
|
||||
task.plugin_id = "superset-backup"
|
||||
task.params = {"dashboard_ids": [1, 42, 3], "environment_id": "env-1"}
|
||||
assert _task_matches_dashboard(task, 42, "env-1") is True
|
||||
|
||||
def test_backup_dashboards_key(self):
|
||||
from src.api.routes.dashboards._projection import _task_matches_dashboard
|
||||
task = MagicMock()
|
||||
task.plugin_id = "superset-backup"
|
||||
task.params = {"dashboards": [42], "env": "env-1"}
|
||||
assert _task_matches_dashboard(task, 42, "env-1") is True
|
||||
|
||||
def test_unrelated_plugin(self):
|
||||
from src.api.routes.dashboards._projection import _task_matches_dashboard
|
||||
task = MagicMock()
|
||||
task.plugin_id = "some-other-plugin"
|
||||
assert _task_matches_dashboard(task, 42, "env-1") is False
|
||||
|
||||
def test_backup_wrong_dashboard(self):
|
||||
from src.api.routes.dashboards._projection import _task_matches_dashboard
|
||||
task = MagicMock()
|
||||
task.plugin_id = "superset-backup"
|
||||
task.params = {"dashboard_ids": [1, 2], "environment_id": "env-1"}
|
||||
assert _task_matches_dashboard(task, 42, "env-1") is False
|
||||
|
||||
def test_backup_wrong_env(self):
|
||||
from src.api.routes.dashboards._projection import _task_matches_dashboard
|
||||
task = MagicMock()
|
||||
task.plugin_id = "superset-backup"
|
||||
task.params = {"dashboard_ids": [42], "environment_id": "env-1"}
|
||||
assert _task_matches_dashboard(task, 42, "env-2") is False
|
||||
# #endregion Test.Api.DashboardProjection
|
||||
263
backend/tests/api/test_environments.py
Normal file
263
backend/tests/api/test_environments.py
Normal file
@@ -0,0 +1,263 @@
|
||||
# #region Test.Api.Environments [C:3] [TYPE Module] [SEMANTICS test,environments,api]
|
||||
# @BRIEF Unit tests for environments API routes — list, schedule, databases.
|
||||
# @RELATION BINDS_TO -> [EnvironmentsApi]
|
||||
# @TEST_EDGE: environment_not_found -> 404
|
||||
# @TEST_EDGE: empty_list -> 200 empty
|
||||
# @TEST_EDGE: superset_connection_fail -> 500
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
def _make_mock_env(
|
||||
id: str = "env-1",
|
||||
name: str = "Production",
|
||||
url: str = "https://superset.example.com",
|
||||
stage: str = "PROD",
|
||||
is_production: bool = True,
|
||||
backup_enabled: bool = True,
|
||||
backup_cron: str = "0 0 * * *",
|
||||
):
|
||||
env = MagicMock()
|
||||
env.id = id
|
||||
env.name = name
|
||||
env.url = url
|
||||
env.stage = stage
|
||||
env.is_production = is_production
|
||||
env.backup_schedule = MagicMock()
|
||||
env.backup_schedule.enabled = backup_enabled
|
||||
env.backup_schedule.cron_expression = backup_cron
|
||||
return env
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.environments import router
|
||||
from src.dependencies import get_current_user, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="admin-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── get_environments ──
|
||||
|
||||
class TestGetEnvironments:
|
||||
"""GET /api/environments"""
|
||||
|
||||
def test_list_environments_success(self):
|
||||
"""Happy path: returns list of environments."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [
|
||||
_make_mock_env(id="env-1", name="Dev", url="https://dev.superset.example.com", stage="DEV", is_production=False),
|
||||
_make_mock_env(id="env-2", name="Prod", url="https://prod.superset.example.com/api/v1", stage="PROD", is_production=True),
|
||||
]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/environments")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == "env-1"
|
||||
assert data[0]["url"] == "https://dev.superset.example.com"
|
||||
assert data[0]["stage"] == "DEV"
|
||||
assert data[1]["stage"] == "PROD"
|
||||
assert data[1]["is_production"] is True
|
||||
|
||||
def test_list_environments_not_a_list(self):
|
||||
"""Non-list environments returns empty list."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = None
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/environments")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_list_environments_empty(self):
|
||||
"""Empty environments list returns 200 with empty array."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/environments")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_list_environments_no_backup_schedule(self):
|
||||
"""Environment without backup schedule returns None schedule."""
|
||||
env = _make_mock_env(backup_enabled=False)
|
||||
env.backup_schedule = None
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/environments")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()[0]["backup_schedule"] is None
|
||||
|
||||
|
||||
# ── update_environment_schedule ──
|
||||
|
||||
class TestUpdateEnvironmentSchedule:
|
||||
"""PUT /api/environments/{id}/schedule"""
|
||||
|
||||
def test_update_schedule_success(self):
|
||||
"""Happy path: schedule updated."""
|
||||
env = _make_mock_env(id="env-1")
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [env]
|
||||
mock_scheduler = MagicMock()
|
||||
|
||||
from src.dependencies import get_config_manager, get_scheduler_service
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_scheduler_service: lambda: mock_scheduler,
|
||||
})
|
||||
resp = client.put("/api/environments/env-1/schedule", json={
|
||||
"enabled": True,
|
||||
"cron_expression": "0 6 * * *",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Schedule updated successfully"
|
||||
assert env.backup_schedule.cron_expression == "0 6 * * *"
|
||||
mock_config.update_environment.assert_called_once_with("env-1", env)
|
||||
mock_scheduler.load_schedules.assert_called_once()
|
||||
|
||||
def test_update_schedule_invalid_cron(self):
|
||||
"""Invalid cron expression returns 422."""
|
||||
from src.dependencies import get_config_manager, get_scheduler_service
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: MagicMock(),
|
||||
get_scheduler_service: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.put("/api/environments/env-1/schedule", json={
|
||||
"enabled": True,
|
||||
"cron_expression": "not-a-cron",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_schedule_env_not_found(self):
|
||||
"""Non-existent environment returns 404."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
|
||||
from src.dependencies import get_config_manager, get_scheduler_service
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_scheduler_service: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.put("/api/environments/env-999/schedule", json={
|
||||
"enabled": True,
|
||||
"cron_expression": "0 0 * * *",
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── get_environment_databases ──
|
||||
|
||||
class TestGetEnvironmentDatabases:
|
||||
"""GET /api/environments/{id}/databases"""
|
||||
|
||||
def test_get_databases_success(self):
|
||||
"""Happy path: returns database list."""
|
||||
env = _make_mock_env(id="env-1")
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_databases_summary.return_value = [
|
||||
{"uuid": "db-1", "database_name": "sales", "engine": "postgresql"},
|
||||
]
|
||||
|
||||
with patch("src.api.routes.environments.AsyncSupersetClient", return_value=mock_client):
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/environments/env-1/databases")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
assert resp.json()[0]["database_name"] == "sales"
|
||||
|
||||
def test_get_databases_env_not_found(self):
|
||||
"""Non-existent environment returns 404."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/environments/env-999/databases")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_databases_connection_fail(self):
|
||||
"""Superset connection failure returns 500."""
|
||||
env = _make_mock_env(id="env-1")
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_databases_summary.side_effect = Exception("Connection refused")
|
||||
|
||||
with patch("src.api.routes.environments.AsyncSupersetClient", return_value=mock_client):
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/environments/env-1/databases")
|
||||
assert resp.status_code == 500
|
||||
assert "Failed to fetch databases" in resp.text
|
||||
|
||||
|
||||
# ── URL normalization ──
|
||||
|
||||
class TestNormalizeSupersetEnvUrl:
|
||||
"""_normalize_superset_env_url"""
|
||||
|
||||
def test_normalize_removes_api_v1(self):
|
||||
from src.api.routes.environments import _normalize_superset_env_url
|
||||
assert _normalize_superset_env_url("https://superset.example.com/api/v1") == "https://superset.example.com"
|
||||
|
||||
def test_normalize_strips_trailing_slash(self):
|
||||
from src.api.routes.environments import _normalize_superset_env_url
|
||||
assert _normalize_superset_env_url("https://superset.example.com/") == "https://superset.example.com"
|
||||
|
||||
def test_normalize_empty_string(self):
|
||||
from src.api.routes.environments import _normalize_superset_env_url
|
||||
assert _normalize_superset_env_url("") == ""
|
||||
|
||||
def test_normalize_noop(self):
|
||||
from src.api.routes.environments import _normalize_superset_env_url
|
||||
assert _normalize_superset_env_url("https://superset.example.com") == "https://superset.example.com"
|
||||
# #endregion Test.Api.Environments
|
||||
302
backend/tests/api/test_git_config_routes.py
Normal file
302
backend/tests/api/test_git_config_routes.py
Normal file
@@ -0,0 +1,302 @@
|
||||
# #region Test.Api.GitConfigRoutes [C:3] [TYPE Module] [SEMANTICS test,git,config,routes]
|
||||
# @BRIEF Unit tests for Git config API routes — CRUD + test connection.
|
||||
# @RELATION BINDS_TO -> [GitConfigRoutes]
|
||||
# @TEST_EDGE: config_not_found -> 404
|
||||
# @TEST_EDGE: connection_fail -> 400
|
||||
# @TEST_EDGE: pat_masking
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.git._config_routes import router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_current_user, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
# Git routers have no prefix — added via app.include_router in app.py
|
||||
app.include_router(router, prefix="/api/git")
|
||||
|
||||
mock_user = User(
|
||||
id="admin-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_db: lambda: MagicMock(),
|
||||
get_current_user: lambda: mock_user,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_mock_config(
|
||||
id: str = "cfg-1",
|
||||
name: str = "My Git",
|
||||
provider: str = "GITHUB",
|
||||
url: str = "https://github.com",
|
||||
pat: str = "ghp_secret123",
|
||||
last_validated: str = "2024-01-01T00:00:00",
|
||||
):
|
||||
from src.models.git import GitStatus
|
||||
config = MagicMock()
|
||||
config.id = id
|
||||
config.name = name
|
||||
config.provider = provider
|
||||
config.url = url
|
||||
config.pat = pat
|
||||
config.status = GitStatus.CONNECTED
|
||||
config.last_validated = last_validated
|
||||
config.default_repository = None
|
||||
config.default_branch = "main"
|
||||
return config
|
||||
|
||||
|
||||
# ── get_git_configs ──
|
||||
|
||||
class TestGetGitConfigs:
|
||||
"""GET /api/git/config"""
|
||||
|
||||
def test_list_configs_success(self):
|
||||
"""Happy path: returns masked configs."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.all.return_value = [_make_mock_config()]
|
||||
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/git/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert data[0]["pat"] == "********"
|
||||
|
||||
def test_list_configs_empty(self):
|
||||
"""Empty list returns 200."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.all.return_value = []
|
||||
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/git/config")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
# ── create_git_config ──
|
||||
|
||||
class TestCreateGitConfig:
|
||||
"""POST /api/git/config"""
|
||||
|
||||
def test_create_config_success(self):
|
||||
"""Happy path: config created."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/git/config", json={
|
||||
"name": "New Git",
|
||||
"provider": "GITHUB",
|
||||
"url": "https://github.com/org",
|
||||
"pat": "ghp_newtoken",
|
||||
"default_branch": "main",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── update_git_config ──
|
||||
|
||||
class TestUpdateGitConfig:
|
||||
"""PUT /api/git/config/{config_id}"""
|
||||
|
||||
def test_update_config_success(self):
|
||||
"""Happy path: config updated."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
existing = _make_mock_config(id="cfg-1", pat="ghp_secret")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = existing
|
||||
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.put("/api/git/config/cfg-1", json={"name": "Updated Git"})
|
||||
assert resp.status_code == 200
|
||||
assert existing.name == "Updated Git"
|
||||
assert resp.json()["pat"] == "********"
|
||||
|
||||
def test_update_config_not_found(self):
|
||||
"""Non-existent config returns 404."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.put("/api/git/config/cfg-999", json={"name": "Ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_config_preserves_pat(self):
|
||||
"""When pat is ********, existing PAT preserved."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
existing = _make_mock_config(id="cfg-1", pat="ghp_secret")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = existing
|
||||
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.put("/api/git/config/cfg-1", json={"pat": "********"})
|
||||
assert resp.status_code == 200
|
||||
assert existing.pat == "ghp_secret"
|
||||
|
||||
|
||||
# ── delete_git_config ──
|
||||
|
||||
class TestDeleteGitConfig:
|
||||
"""DELETE /api/git/config/{config_id}"""
|
||||
|
||||
def test_delete_config_success(self):
|
||||
"""Happy path: config deleted."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
existing = _make_mock_config()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = existing
|
||||
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.delete("/api/git/config/cfg-1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
mock_db.delete.assert_called_once_with(existing)
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_config_not_found(self):
|
||||
"""Non-existent config returns 404."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.delete("/api/git/config/cfg-999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── test_git_config ──
|
||||
|
||||
class TestTestGitConfig:
|
||||
"""POST /api/git/config/test"""
|
||||
|
||||
def test_connection_success(self):
|
||||
"""Success returns 200."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
mock_service = AsyncMock()
|
||||
mock_service.test_connection.return_value = True
|
||||
|
||||
with patch("src.api.routes.git._config_routes.get_git_service", return_value=mock_service):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/git/config/test", json={
|
||||
"name": "Test",
|
||||
"provider": "GITHUB",
|
||||
"url": "https://github.com",
|
||||
"pat": "ghp_test",
|
||||
"default_branch": "main",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
|
||||
def test_connection_fail(self):
|
||||
"""Failure returns 400."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
mock_service = AsyncMock()
|
||||
mock_service.test_connection.return_value = False
|
||||
|
||||
with patch("src.api.routes.git._config_routes.get_git_service", return_value=mock_service):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/git/config/test", json={
|
||||
"name": "Test",
|
||||
"provider": "GITHUB",
|
||||
"url": "https://github.com",
|
||||
"pat": "ghp_fail",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "Connection failed" in resp.text
|
||||
|
||||
def test_connection_with_masked_pat_and_config_id(self):
|
||||
"""Masked PAT resolved from existing config by config_id."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
existing = _make_mock_config(id="cfg-1", pat="ghp_resolved")
|
||||
# First filter call (by id) returns existing
|
||||
mock_q = MagicMock()
|
||||
mock_f = MagicMock()
|
||||
mock_f.first.return_value = existing
|
||||
mock_q.filter.return_value = mock_f
|
||||
mock_db.query.return_value = mock_q
|
||||
|
||||
mock_service = AsyncMock()
|
||||
mock_service.test_connection.return_value = True
|
||||
|
||||
with patch("src.api.routes.git._config_routes.get_git_service", return_value=mock_service):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/git/config/test", json={
|
||||
"name": "Test",
|
||||
"provider": "GITHUB",
|
||||
"url": "https://github.com",
|
||||
"pat": "********",
|
||||
"config_id": "cfg-1",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
mock_service.test_connection.assert_called_with("GITHUB", "https://github.com", "ghp_resolved")
|
||||
|
||||
def test_connection_with_masked_pat_and_url_fallback(self):
|
||||
"""Masked PAT resolved by URL match when config_id not provided."""
|
||||
from src.core.database import get_db
|
||||
mock_db = MagicMock()
|
||||
existing = _make_mock_config(id="cfg-2", pat="ghp_url_resolved")
|
||||
|
||||
# For test_git_config: first query by id returns None, second by url returns existing
|
||||
class _MockFilter:
|
||||
def __init__(self, return_values):
|
||||
self.return_values = return_values
|
||||
self.call_count = 0
|
||||
def first(self):
|
||||
val = self.return_values[self.call_count] if self.call_count < len(self.return_values) else self.return_values[-1]
|
||||
self.call_count += 1
|
||||
return val
|
||||
|
||||
mock_q = MagicMock()
|
||||
mock_f = _MockFilter([None, existing])
|
||||
mock_q.filter.return_value = mock_f
|
||||
mock_db.query.return_value = mock_q
|
||||
|
||||
mock_service = AsyncMock()
|
||||
mock_service.test_connection.return_value = True
|
||||
|
||||
with patch("src.api.routes.git._config_routes.get_git_service", return_value=mock_service):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/git/config/test", json={
|
||||
"name": "Test",
|
||||
"provider": "GITHUB",
|
||||
"url": "https://github.com",
|
||||
"pat": "********",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
mock_service.test_connection.assert_called_with("GITHUB", "https://github.com", "ghp_url_resolved")
|
||||
# #endregion Test.Api.GitConfigRoutes
|
||||
41
backend/tests/api/test_git_deps.py
Normal file
41
backend/tests/api/test_git_deps.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# #region Test.Api.GitDeps [C:2] [TYPE Module] [SEMANTICS test,git,deps]
|
||||
# @BRIEF Unit tests for git dependency helper.
|
||||
# @RELATION BINDS_TO -> [GitDeps]
|
||||
# @TEST_EDGE: monkeypatch_resolution
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
class TestGetGitService:
|
||||
"""get_git_service resolves from sys.modules at call time."""
|
||||
|
||||
def test_get_git_service_resolves_correctly(self):
|
||||
"""Verify it resolves from sys.modules."""
|
||||
import sys as sys_mod
|
||||
from src.api.routes.git._deps import get_git_service
|
||||
from src.api.routes import git as git_routes
|
||||
|
||||
mock_service = MagicMock()
|
||||
# Monkeypatch via sys.modules
|
||||
sys_mod.modules["src.api.routes.git"].git_service = mock_service
|
||||
|
||||
try:
|
||||
result = get_git_service()
|
||||
assert result is mock_service
|
||||
finally:
|
||||
# Restore
|
||||
sys_mod.modules["src.api.routes.git"].git_service = git_routes.git_service
|
||||
|
||||
def test_max_repository_status_batch(self):
|
||||
"""Guard value is 50."""
|
||||
from src.api.routes.git._deps import MAX_REPOSITORY_STATUS_BATCH
|
||||
assert MAX_REPOSITORY_STATUS_BATCH == 50
|
||||
# #endregion Test.Api.GitDeps
|
||||
87
backend/tests/api/test_git_environment_routes.py
Normal file
87
backend/tests/api/test_git_environment_routes.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# #region Test.Api.GitEnvironmentRoutes [C:2] [TYPE Module] [SEMANTICS test,git,environment,routes]
|
||||
# @BRIEF Unit tests for Git environment routes.
|
||||
# @RELATION BINDS_TO -> [GitEnvironmentRoutes]
|
||||
# @TEST_EDGE: empty_list -> 200
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.git._environment_routes import router
|
||||
from src.dependencies import get_current_user, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/git")
|
||||
|
||||
mock_user = User(
|
||||
id="admin-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestGetEnvironments:
|
||||
"""GET /api/git/environments"""
|
||||
|
||||
def test_list_environments_success(self):
|
||||
"""Happy path: returns deployment environments."""
|
||||
mock_config = MagicMock()
|
||||
env1 = MagicMock()
|
||||
env1.id = "env-1"
|
||||
env1.name = "Production"
|
||||
env1.url = "https://superset.prod.com"
|
||||
env2 = MagicMock()
|
||||
env2.id = "env-2"
|
||||
env2.name = "Staging"
|
||||
env2.url = "https://superset.staging.com"
|
||||
mock_config.get_environments.return_value = [env1, env2]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/git/environments")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == "env-1"
|
||||
assert data[0]["name"] == "Production"
|
||||
assert data[0]["superset_url"] == "https://superset.prod.com"
|
||||
assert data[0]["is_active"] is True
|
||||
|
||||
def test_list_environments_empty(self):
|
||||
"""Empty list returns 200."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/git/environments")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
# #endregion Test.Api.GitEnvironmentRoutes
|
||||
481
backend/tests/api/test_git_helpers.py
Normal file
481
backend/tests/api/test_git_helpers.py
Normal file
@@ -0,0 +1,481 @@
|
||||
# #region Test.Api.GitHelpers [C:3] [TYPE Module] [SEMANTICS test,git,helpers]
|
||||
# @BRIEF Unit tests for git helper functions.
|
||||
# @RELATION BINDS_TO -> [GitHelpers]
|
||||
# @TEST_EDGE: no_repo_path
|
||||
# @TEST_EDGE: config_not_found -> 404
|
||||
# @TEST_EDGE: slug_not_found -> 404
|
||||
# @TEST_EDGE: empty_ref -> 400
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
# ── _build_no_repo_status_payload ──
|
||||
|
||||
class TestBuildNoRepoStatusPayload:
|
||||
def test_payload_structure(self):
|
||||
from src.api.routes.git._helpers import _build_no_repo_status_payload
|
||||
payload = _build_no_repo_status_payload()
|
||||
assert payload["sync_status"] == "NO_REPO"
|
||||
assert payload["has_repo"] is False
|
||||
assert payload["is_dirty"] is False
|
||||
assert payload["untracked_files"] == []
|
||||
|
||||
|
||||
# ── _handle_unexpected_git_route_error ──
|
||||
|
||||
class TestHandleUnexpectedGitRouteError:
|
||||
def test_raises_http_500(self):
|
||||
from fastapi import HTTPException
|
||||
from src.api.routes.git._helpers import _handle_unexpected_git_route_error
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_handle_unexpected_git_route_error("test_route", RuntimeError("boom"))
|
||||
assert exc.value.status_code == 500
|
||||
assert "test_route failed" in exc.value.detail
|
||||
|
||||
|
||||
# ── _resolve_repository_status ──
|
||||
|
||||
class TestResolveRepositoryStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_repo_path_exists(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service._get_repo_path = AsyncMock(return_value="/tmp/some-repo")
|
||||
mock_service.get_status = AsyncMock(return_value={"sync_status": "OK"})
|
||||
|
||||
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
|
||||
patch("os.path.exists", return_value=True):
|
||||
from src.api.routes.git._helpers import _resolve_repository_status
|
||||
result = await _resolve_repository_status(42)
|
||||
assert result["sync_status"] == "OK"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repo_path_missing(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service._get_repo_path = AsyncMock(return_value="/nonexistent")
|
||||
|
||||
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
|
||||
patch("os.path.exists", return_value=False):
|
||||
from src.api.routes.git._helpers import _resolve_repository_status
|
||||
result = await _resolve_repository_status(42)
|
||||
assert result["sync_status"] == "NO_REPO"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_returns_404(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service._get_repo_path = AsyncMock(return_value="/tmp/repo")
|
||||
mock_service.get_status = AsyncMock(side_effect=HTTPException(status_code=404, detail="No repo"))
|
||||
|
||||
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
|
||||
patch("os.path.exists", return_value=True):
|
||||
from src.api.routes.git._helpers import _resolve_repository_status
|
||||
result = await _resolve_repository_status(42)
|
||||
assert result["sync_status"] == "NO_REPO"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_raises_other_http(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service._get_repo_path = AsyncMock(return_value="/tmp/repo")
|
||||
mock_service.get_status = AsyncMock(side_effect=HTTPException(status_code=500, detail="Server error"))
|
||||
|
||||
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
|
||||
patch("os.path.exists", return_value=True):
|
||||
from src.api.routes.git._helpers import _resolve_repository_status
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_repository_status(42)
|
||||
assert exc.value.status_code == 500
|
||||
|
||||
|
||||
# ── _get_git_config_or_404 ──
|
||||
|
||||
class TestGetGitConfigOr404:
|
||||
def test_config_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = MagicMock(id="cfg-1")
|
||||
|
||||
from src.api.routes.git._helpers import _get_git_config_or_404
|
||||
result = _get_git_config_or_404(mock_db, "cfg-1")
|
||||
assert result is not None
|
||||
|
||||
def test_config_not_found(self):
|
||||
from fastapi import HTTPException
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.api.routes.git._helpers import _get_git_config_or_404
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_get_git_config_or_404(mock_db, "cfg-missing")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ── _find_dashboard_id_by_slug (sync) ──
|
||||
|
||||
class TestFindDashboardIdBySlug:
|
||||
@pytest.mark.asyncio
|
||||
async def test_found(self):
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (1, [{"id": 42}])
|
||||
|
||||
from src.api.routes.git._helpers import _find_dashboard_id_by_slug
|
||||
result = await _find_dashboard_id_by_slug(client, "my-slug")
|
||||
assert result == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_found(self):
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (0, [])
|
||||
|
||||
from src.api.routes.git._helpers import _find_dashboard_id_by_slug
|
||||
result = await _find_dashboard_id_by_slug(client, "ghost")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_then_second_query(self):
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.side_effect = [
|
||||
Exception("Fail"),
|
||||
(1, [{"id": 99}]),
|
||||
]
|
||||
|
||||
from src.api.routes.git._helpers import _find_dashboard_id_by_slug
|
||||
result = await _find_dashboard_id_by_slug(client, "retry")
|
||||
assert result == 99
|
||||
|
||||
|
||||
# ── _resolve_dashboard_id_from_ref (sync) ──
|
||||
|
||||
class TestResolveDashboardIdFromRef:
|
||||
@pytest.mark.asyncio
|
||||
async def test_numeric_ref(self):
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
|
||||
result = await _resolve_dashboard_id_from_ref("123", MagicMock())
|
||||
assert result == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_ref(self):
|
||||
mock_config = MagicMock()
|
||||
env = MagicMock()
|
||||
env.id = "env-1"
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
client = MagicMock()
|
||||
client.get_dashboards_page.return_value = (1, [{"id": 42}])
|
||||
|
||||
with patch("src.api.routes.git._helpers.SupersetClient", return_value=client):
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
|
||||
result = await _resolve_dashboard_id_from_ref("my-slug", mock_config, "env-1")
|
||||
assert result == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_ref(self):
|
||||
from fastapi import HTTPException
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref("", MagicMock())
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_no_env_id(self):
|
||||
from fastapi import HTTPException
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref("slug", MagicMock())
|
||||
assert exc.value.status_code == 400
|
||||
assert "env_id is required" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_not_found(self):
|
||||
from fastapi import HTTPException
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref("slug", mock_config, "env-ghost")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_not_found(self):
|
||||
from fastapi import HTTPException
|
||||
mock_config = MagicMock()
|
||||
env = MagicMock()
|
||||
env.id = "env-1"
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
client = MagicMock()
|
||||
client.get_dashboards_page.return_value = (0, [])
|
||||
|
||||
with patch("src.api.routes.git._helpers.SupersetClient", return_value=client):
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref("ghost-slug", mock_config, "env-1")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ── _find_dashboard_id_by_slug_async ──
|
||||
|
||||
class TestFindDashboardIdBySlugAsync:
|
||||
@pytest.mark.asyncio
|
||||
async def test_found(self):
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (1, [{"id": 42}])
|
||||
|
||||
from src.api.routes.git._helpers import _find_dashboard_id_by_slug_async
|
||||
result = await _find_dashboard_id_by_slug_async(client, "slug")
|
||||
assert result == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_found(self):
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (0, [])
|
||||
|
||||
from src.api.routes.git._helpers import _find_dashboard_id_by_slug_async
|
||||
result = await _find_dashboard_id_by_slug_async(client, "ghost")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── _resolve_dashboard_id_from_ref_async ──
|
||||
|
||||
class TestResolveDashboardIdFromRefAsync:
|
||||
@pytest.mark.asyncio
|
||||
async def test_numeric_ref(self):
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
|
||||
result = await _resolve_dashboard_id_from_ref_async("123", MagicMock())
|
||||
assert result == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_ref(self):
|
||||
mock_config = MagicMock()
|
||||
env = MagicMock()
|
||||
env.id = "env-1"
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (1, [{"id": 42}])
|
||||
client.aclose = AsyncMock()
|
||||
|
||||
with patch("src.api.routes.git._helpers.AsyncSupersetClient", return_value=client):
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
|
||||
result = await _resolve_dashboard_id_from_ref_async("slug", mock_config, "env-1")
|
||||
assert result == 42
|
||||
client.aclose.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_ref(self):
|
||||
from fastapi import HTTPException
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref_async("", MagicMock())
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_not_found_async(self):
|
||||
from fastapi import HTTPException
|
||||
mock_config = MagicMock()
|
||||
env = MagicMock()
|
||||
env.id = "env-1"
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_dashboards_page.return_value = (0, [])
|
||||
client.aclose = AsyncMock()
|
||||
|
||||
with patch("src.api.routes.git._helpers.AsyncSupersetClient", return_value=client):
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref_async("ghost", mock_config, "env-1")
|
||||
assert exc.value.status_code == 404
|
||||
client.aclose.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_env_id(self):
|
||||
from fastapi import HTTPException
|
||||
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_dashboard_id_from_ref_async("slug", MagicMock())
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ── _resolve_repo_key_from_ref ──
|
||||
|
||||
class TestResolveRepoKeyFromRef:
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_ref_returns_slug(self):
|
||||
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
|
||||
result = await _resolve_repo_key_from_ref("my-dash", 42, MagicMock())
|
||||
assert result == "my-dash"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_numeric_ref_returns_dashboard_id_fallback(self):
|
||||
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
|
||||
result = await _resolve_repo_key_from_ref("123", 42, MagicMock())
|
||||
assert result == "dashboard-42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_numeric_ref_with_env_lookup(self):
|
||||
mock_config = MagicMock()
|
||||
env = MagicMock()
|
||||
env.id = "env-1"
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
client = MagicMock()
|
||||
client.get_dashboard.return_value = {"result": {"slug": "real-slug"}}
|
||||
|
||||
with patch("src.api.routes.git._helpers.SupersetClient", return_value=client):
|
||||
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
|
||||
result = await _resolve_repo_key_from_ref("123", 42, mock_config, "env-1")
|
||||
assert result == "real-slug"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_lookup_fails_uses_fallback(self):
|
||||
mock_config = MagicMock()
|
||||
env = MagicMock()
|
||||
env.id = "env-1"
|
||||
mock_config.get_environments.return_value = [env]
|
||||
|
||||
client = MagicMock()
|
||||
client.get_dashboard.side_effect = Exception("API error")
|
||||
|
||||
with patch("src.api.routes.git._helpers.SupersetClient", return_value=client):
|
||||
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
|
||||
result = await _resolve_repo_key_from_ref("123", 42, mock_config, "env-1")
|
||||
assert result == "dashboard-42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_ref_uses_fallback(self):
|
||||
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
|
||||
result = await _resolve_repo_key_from_ref("", 42, MagicMock())
|
||||
assert result == "dashboard-42"
|
||||
|
||||
|
||||
# ── _sanitize_optional_identity_value ──
|
||||
|
||||
class TestSanitizeOptionalIdentityValue:
|
||||
def test_none(self):
|
||||
from src.api.routes.git._helpers import _sanitize_optional_identity_value
|
||||
assert _sanitize_optional_identity_value(None) is None
|
||||
|
||||
def test_empty(self):
|
||||
from src.api.routes.git._helpers import _sanitize_optional_identity_value
|
||||
assert _sanitize_optional_identity_value("") is None
|
||||
|
||||
def test_whitespace(self):
|
||||
from src.api.routes.git._helpers import _sanitize_optional_identity_value
|
||||
assert _sanitize_optional_identity_value(" ") is None
|
||||
|
||||
def test_valid(self):
|
||||
from src.api.routes.git._helpers import _sanitize_optional_identity_value
|
||||
assert _sanitize_optional_identity_value(" alice ") == "alice"
|
||||
|
||||
|
||||
# ── _resolve_current_user_git_identity ──
|
||||
|
||||
class TestResolveCurrentUserGitIdentity:
|
||||
def test_db_is_none(self):
|
||||
from src.api.routes.git._helpers import _resolve_current_user_git_identity
|
||||
assert _resolve_current_user_git_identity(None, MagicMock()) is None
|
||||
|
||||
def test_no_user_id(self):
|
||||
from src.api.routes.git._helpers import _resolve_current_user_git_identity
|
||||
user = MagicMock()
|
||||
user.id = None
|
||||
assert _resolve_current_user_git_identity(MagicMock(), user) is None
|
||||
|
||||
def test_preference_found(self):
|
||||
mock_db = MagicMock()
|
||||
pref = MagicMock()
|
||||
pref.git_username = "alice"
|
||||
pref.git_email = "alice@example.com"
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = pref
|
||||
|
||||
user = MagicMock()
|
||||
user.id = "user-1"
|
||||
|
||||
from src.api.routes.git._helpers import _resolve_current_user_git_identity
|
||||
result = _resolve_current_user_git_identity(mock_db, user)
|
||||
assert result == ("alice", "alice@example.com")
|
||||
|
||||
def test_preference_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
user = MagicMock()
|
||||
user.id = "user-1"
|
||||
|
||||
from src.api.routes.git._helpers import _resolve_current_user_git_identity
|
||||
assert _resolve_current_user_git_identity(mock_db, user) is None
|
||||
|
||||
def test_preference_missing_git_fields(self):
|
||||
mock_db = MagicMock()
|
||||
pref = MagicMock()
|
||||
pref.git_username = None
|
||||
pref.git_email = None
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = pref
|
||||
|
||||
user = MagicMock()
|
||||
user.id = "user-1"
|
||||
|
||||
from src.api.routes.git._helpers import _resolve_current_user_git_identity
|
||||
assert _resolve_current_user_git_identity(mock_db, user) is None
|
||||
|
||||
def test_query_exception(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = Exception("DB error")
|
||||
|
||||
user = MagicMock()
|
||||
user.id = "user-1"
|
||||
|
||||
from src.api.routes.git._helpers import _resolve_current_user_git_identity
|
||||
assert _resolve_current_user_git_identity(mock_db, user) is None
|
||||
|
||||
def test_db_without_query_attr(self):
|
||||
from src.api.routes.git._helpers import _resolve_current_user_git_identity
|
||||
assert _resolve_current_user_git_identity(object(), MagicMock()) is None
|
||||
|
||||
|
||||
# ── _apply_git_identity_from_profile ──
|
||||
|
||||
class TestApplyGitIdentityFromProfile:
|
||||
@pytest.mark.asyncio
|
||||
async def test_identity_resolved_and_applied(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service.configure_identity = AsyncMock()
|
||||
|
||||
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
|
||||
patch("src.api.routes.git._helpers._resolve_current_user_git_identity",
|
||||
return_value=("alice", "alice@example.com")):
|
||||
from src.api.routes.git._helpers import _apply_git_identity_from_profile
|
||||
await _apply_git_identity_from_profile(42, MagicMock(), MagicMock())
|
||||
mock_service.configure_identity.assert_awaited_once_with(42, "alice", "alice@example.com")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identity_not_resolved(self):
|
||||
with patch("src.api.routes.git._helpers._resolve_current_user_git_identity",
|
||||
return_value=None):
|
||||
from src.api.routes.git._helpers import _apply_git_identity_from_profile
|
||||
# Should not raise
|
||||
await _apply_git_identity_from_profile(42, MagicMock(), MagicMock())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_has_no_configure_identity(self):
|
||||
mock_service = MagicMock(spec=[]) # no configure_identity attribute
|
||||
|
||||
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
|
||||
patch("src.api.routes.git._helpers._resolve_current_user_git_identity",
|
||||
return_value=("alice", "alice@example.com")):
|
||||
from src.api.routes.git._helpers import _apply_git_identity_from_profile
|
||||
# Should not raise
|
||||
await _apply_git_identity_from_profile(42, MagicMock(), MagicMock())
|
||||
# #endregion Test.Api.GitHelpers
|
||||
163
backend/tests/api/test_health.py
Normal file
163
backend/tests/api/test_health.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# #region Test.Api.Health [C:3] [TYPE Module] [SEMANTICS test,health,api]
|
||||
# @BRIEF Unit tests for health API routes — summary, delete report.
|
||||
# @RELATION BINDS_TO -> [health_router]
|
||||
# @TEST_EDGE: feature_disabled -> 404
|
||||
# @TEST_EDGE: report_not_found -> 404
|
||||
# @TEST_EDGE: environment_filter -> 200
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.health import router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user, get_task_manager, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="admin-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_db: lambda: MagicMock(),
|
||||
get_config_manager: lambda: MagicMock(),
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
get_current_user: lambda: mock_user,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestGetHealthSummary:
|
||||
"""GET /api/health/summary"""
|
||||
|
||||
def test_summary_success(self):
|
||||
"""Happy path: returns health summary."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_config.return_value.settings.features.health_monitor = True
|
||||
|
||||
mock_service = AsyncMock()
|
||||
mock_service.get_health_summary.return_value = {
|
||||
"items": [],
|
||||
"pass_count": 0,
|
||||
"warn_count": 0,
|
||||
"fail_count": 0,
|
||||
"unknown_count": 0,
|
||||
}
|
||||
|
||||
with patch("src.api.routes.health.HealthService", return_value=mock_service):
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/health/summary")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_summary_with_environment_filter(self):
|
||||
"""Environment filter passed to service."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_config.return_value.settings.features.health_monitor = True
|
||||
|
||||
mock_service = AsyncMock()
|
||||
mock_service.get_health_summary.return_value = {
|
||||
"items": [],
|
||||
"pass_count": 0,
|
||||
"warn_count": 0,
|
||||
"fail_count": 0,
|
||||
"unknown_count": 0,
|
||||
}
|
||||
|
||||
with patch("src.api.routes.health.HealthService", return_value=mock_service):
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/health/summary?environment_id=prod")
|
||||
assert resp.status_code == 200
|
||||
mock_service.get_health_summary.assert_called_with(environment_id="prod")
|
||||
|
||||
def test_summary_feature_disabled(self):
|
||||
"""Health monitor disabled returns 404."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_config.return_value.settings.features.health_monitor = False
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/health/summary")
|
||||
assert resp.status_code == 404
|
||||
assert "Health monitor feature is disabled" in resp.text
|
||||
|
||||
|
||||
class TestDeleteHealthReport:
|
||||
"""DELETE /api/health/summary/{record_id}"""
|
||||
|
||||
def test_delete_report_success(self):
|
||||
"""Happy path: report deleted returns 204."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_config.return_value.settings.features.health_monitor = True
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.delete_validation_report.return_value = True
|
||||
|
||||
with patch("src.api.routes.health.HealthService", return_value=mock_service):
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.delete("/api/health/summary/rec-1")
|
||||
assert resp.status_code == 204
|
||||
mock_service.delete_validation_report.assert_called_once()
|
||||
|
||||
def test_delete_report_not_found(self):
|
||||
"""Non-existent report returns 404."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_config.return_value.settings.features.health_monitor = True
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.delete_validation_report.return_value = False
|
||||
|
||||
with patch("src.api.routes.health.HealthService", return_value=mock_service):
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.delete("/api/health/summary/rec-missing")
|
||||
assert resp.status_code == 404
|
||||
assert "Health report not found" in resp.text
|
||||
|
||||
def test_delete_report_feature_disabled(self):
|
||||
"""Health monitor disabled returns 404."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_config.return_value.settings.features.health_monitor = False
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: MagicMock(),
|
||||
})
|
||||
resp = client.delete("/api/health/summary/rec-1")
|
||||
assert resp.status_code == 404
|
||||
# #endregion Test.Api.Health
|
||||
109
backend/tests/api/test_plugins.py
Normal file
109
backend/tests/api/test_plugins.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# #region Test.Api.Plugins [C:2] [TYPE Module] [SEMANTICS test,plugins,api]
|
||||
# @BRIEF Unit tests for plugins API route.
|
||||
# @RELATION BINDS_TO -> [PluginsRouter]
|
||||
# @TEST_EDGE: empty_list -> 200
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.plugins import router
|
||||
from src.dependencies import get_current_user, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
# Plugins router has no prefix — must add it when including
|
||||
app.include_router(router, prefix="/api/plugins")
|
||||
|
||||
mock_user = User(
|
||||
id="admin-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestListPlugins:
|
||||
"""GET /api/plugins"""
|
||||
|
||||
def test_list_plugins_success(self):
|
||||
"""Happy path: returns list of plugin configs."""
|
||||
mock_loader = MagicMock()
|
||||
mock_loader.get_all_plugin_configs.return_value = [
|
||||
MagicMock(
|
||||
name="superset-migration",
|
||||
version="1.0.0",
|
||||
enabled=True,
|
||||
description="Migration plugin",
|
||||
),
|
||||
MagicMock(
|
||||
name="superset-backup",
|
||||
version="2.0.0",
|
||||
enabled=False,
|
||||
description="Backup plugin",
|
||||
),
|
||||
]
|
||||
|
||||
from src.dependencies import get_plugin_loader
|
||||
client = _make_client({get_plugin_loader: lambda: mock_loader})
|
||||
resp = client.get("/api/plugins")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 2
|
||||
|
||||
def test_list_plugins_empty(self):
|
||||
"""Empty plugin list returns 200 with empty array."""
|
||||
mock_loader = MagicMock()
|
||||
mock_loader.get_all_plugin_configs.return_value = []
|
||||
|
||||
from src.dependencies import get_plugin_loader
|
||||
client = _make_client({get_plugin_loader: lambda: mock_loader})
|
||||
resp = client.get("/api/plugins")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_list_plugins_no_permission(self):
|
||||
"""Without READ permission returns 403."""
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
from src.dependencies import get_current_user
|
||||
|
||||
# User without Admin role and without permissions
|
||||
app = FastAPI()
|
||||
from src.api.routes.plugins import router
|
||||
app.include_router(router, prefix="/api/plugins")
|
||||
app.dependency_overrides[get_current_user] = lambda: User(
|
||||
id="u1", username="regular", email="u@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[],
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/plugins")
|
||||
assert resp.status_code == 403
|
||||
# #endregion Test.Api.Plugins
|
||||
Reference in New Issue
Block a user