# #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 datetime import datetime from src.core.database import get_auth_db mock_session = MagicMock() mock_repo = MagicMock() mock_repo.get_user_by_username.return_value = None # Build a proper role mock with string attributes, not MagicMock(name=...) def _get_role_by_name(name): if name != "Admin": return None role = MagicMock() role.id = f"role-{name}" role.name = name role.description = "Administrator role" role.permissions = [] return role mock_repo.get_role_by_name.side_effect = _get_role_by_name # Make mock_session.refresh set fields the response schema needs def _refresh_side_effect(obj): obj.id = "new-user-id" obj.created_at = datetime.now() mock_session.refresh.side_effect = _refresh_side_effect 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.auth_source = "LOCAL" existing_user.created_at = __import__("datetime").datetime.now() 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 def test_update_user_password_and_active(self): """Update with password and is_active covers lines 125, 127.""" 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 = "testuser" existing_user.email = "old@example.com" existing_user.is_active = False existing_user.auth_source = "LOCAL" existing_user.created_at = __import__("datetime").datetime.now() 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={ "password": "NewStrongPass1", "is_active": False, }) assert resp.status_code == 200 assert existing_user.is_active is False mock_session.commit.assert_called_once() def test_update_user_with_roles(self): """Update with roles list covers lines 130-134.""" 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 = "testuser" existing_user.email = "old@example.com" existing_user.is_active = True existing_user.auth_source = "LOCAL" existing_user.created_at = __import__("datetime").datetime.now() existing_user.roles = [] mock_repo.get_user_by_id.return_value = existing_user def _get_role_by_name(name): role = MagicMock() role.id = f"role-{name}" role.name = name role.description = "A role" role.permissions = [] return role mock_repo.get_role_by_name.side_effect = _get_role_by_name 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={ "roles": ["Editor"], }) assert resp.status_code == 200 assert len(existing_user.roles) == 1 mock_session.commit.assert_called_once() 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_perm = MagicMock() mock_perm.id = "perm-1" mock_perm.resource = "users" mock_perm.action = "read" mock_repo.get_permission_by_id.return_value = mock_perm def _refresh_side_effect(obj): obj.id = "new-role-id" mock_session.refresh.side_effect = _refresh_side_effect 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 def test_create_role_with_string_permission(self): """Create role with resource:action string permission (covers lines 220-221).""" mock_session = MagicMock() mock_session.query.return_value.filter.return_value.first.return_value = None mock_repo = MagicMock() # get_permission_by_id returns None -> falls through to split mock_repo.get_permission_by_id.return_value = None mock_perm = MagicMock() mock_perm.id = "perm-resolved" mock_perm.resource = "users" mock_perm.action = "read" mock_repo.get_permission_by_resource_action.return_value = mock_perm def _refresh_side_effect(obj): obj.id = "new-role-id" mock_session.refresh.side_effect = _refresh_side_effect 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": ["users:read"], }) assert resp.status_code == 201 mock_repo.get_permission_by_resource_action.assert_called_once_with("users", "read") mock_session.add.assert_called_once() 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 def test_update_role_with_permissions(self): """Update role with permission changes (covers lines 261-269).""" 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 mock_perm = MagicMock() mock_perm.id = "perm-1" mock_perm.resource = "test" mock_perm.action = "read" mock_repo.get_permission_by_id.return_value = mock_perm 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": "Updated", "permissions": ["perm-1"], }) assert resp.status_code == 200 assert existing_role.name == "Updated" assert len(existing_role.permissions) == 1 def test_update_role_with_string_permissions(self): """Update role with resource:action permission strings (covers lines 261-269 fallback).""" 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 # get_permission_by_id returns None -> falls through mock_repo.get_permission_by_id.return_value = None mock_perm = MagicMock() mock_perm.id = "perm-resolved" mock_perm.resource = "users" mock_perm.action = "write" mock_repo.get_permission_by_resource_action.return_value = mock_perm 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": "Updated", "permissions": ["users:write"], }) assert resp.status_code == 200 mock_repo.get_permission_by_resource_action.assert_called_once_with("users", "write") 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() mock_session.add = MagicMock() def _refresh_side_effect(obj): obj.id = "new-mapping-id" mock_session.refresh.side_effect = _refresh_side_effect 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