feat(auth): implement group-role mapping and permission system
Architecture changes: - Permission format: domain.category:action (e.g., control-room.general:admin) - Decoupled groups from roles via group_roles mapping table - Groups are organizational (synced from Authentik) - Roles are permissions (admin-managed via API) New features: - require_permission() and require_any_permission() dependency factories - Action hierarchy: admin > editor > user > viewer - Global admin override (admin.general:admin grants all) - Group-role management endpoints (assign/remove roles) - GET /auth/roles endpoint to list all roles Database changes: - Added category column to roles table (default: general) - Removed authentik_group column (decoupled) - Added group_roles association table - Added user_groups association table - Migration updates role names to domain.general:action format Tests: - 67 new tests for auth service and controller - Covers token validation, user sync, role sync - Covers group-role assignment/removal - Covers schema conversions and permission system 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
075b0ec297
commit
7752cd9d23
@@ -0,0 +1,517 @@
|
||||
"""Tests for authentication controller endpoints."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OpenAPI Spec Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAuthOpenAPISpec:
|
||||
"""Test that auth endpoints are documented in OpenAPI spec."""
|
||||
|
||||
def test_auth_sync_in_openapi(self, client):
|
||||
"""Auth sync endpoint should be in OpenAPI spec."""
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
spec = response.json()
|
||||
assert "/auth/sync" in spec["paths"]
|
||||
assert "post" in spec["paths"]["/auth/sync"]
|
||||
|
||||
def test_auth_users_in_openapi(self, client):
|
||||
"""Auth users endpoint should be in OpenAPI spec."""
|
||||
response = client.get("/openapi.json")
|
||||
spec = response.json()
|
||||
assert "/auth/users" in spec["paths"]
|
||||
assert "get" in spec["paths"]["/auth/users"]
|
||||
|
||||
def test_auth_groups_in_openapi(self, client):
|
||||
"""Auth groups endpoint should be in OpenAPI spec."""
|
||||
response = client.get("/openapi.json")
|
||||
spec = response.json()
|
||||
assert "/auth/groups" in spec["paths"]
|
||||
assert "get" in spec["paths"]["/auth/groups"]
|
||||
|
||||
def test_auth_roles_in_openapi(self, client):
|
||||
"""Auth roles endpoint should be in OpenAPI spec."""
|
||||
response = client.get("/openapi.json")
|
||||
spec = response.json()
|
||||
assert "/auth/roles" in spec["paths"]
|
||||
assert "get" in spec["paths"]["/auth/roles"]
|
||||
|
||||
def test_group_role_assignment_in_openapi(self, client):
|
||||
"""Group role assignment endpoints should be in OpenAPI spec."""
|
||||
response = client.get("/openapi.json")
|
||||
spec = response.json()
|
||||
path = "/auth/groups/{group_id}/roles/{role_id}"
|
||||
assert path in spec["paths"]
|
||||
assert "post" in spec["paths"][path] # Assign
|
||||
assert "delete" in spec["paths"][path] # Remove
|
||||
|
||||
def test_sync_from_authentik_endpoints(self, client):
|
||||
"""Sync from Authentik endpoints should be in OpenAPI spec."""
|
||||
response = client.get("/openapi.json")
|
||||
spec = response.json()
|
||||
assert "/auth/users/sync-from-authentik" in spec["paths"]
|
||||
assert "/auth/groups/sync-from-authentik" in spec["paths"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Controller Module Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAuthControllerModule:
|
||||
"""Test auth controller module imports and configuration."""
|
||||
|
||||
def test_controller_imports(self):
|
||||
"""Auth controller should be importable."""
|
||||
from src.domains.auth.controller import AuthController, auth_controller
|
||||
assert AuthController is not None
|
||||
assert auth_controller is not None
|
||||
|
||||
def test_controller_has_correct_prefix(self):
|
||||
"""Auth controller should have /auth prefix."""
|
||||
from src.domains.auth.controller import auth_controller
|
||||
assert auth_controller.prefix == "/auth"
|
||||
|
||||
def test_controller_has_correct_tags(self):
|
||||
"""Auth controller should have Authentication tag."""
|
||||
from src.domains.auth.controller import auth_controller
|
||||
assert "Authentication" in auth_controller.tags
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Sync Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSyncEndpoint:
|
||||
"""Test POST /auth/sync endpoint."""
|
||||
|
||||
def test_sync_requires_access_token(self, client):
|
||||
"""Sync should require access_token in body."""
|
||||
response = client.post("/auth/sync", json={})
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
def test_sync_with_invalid_token(self, client):
|
||||
"""Sync should return 401 for invalid token."""
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.validate_token = AsyncMock(
|
||||
side_effect=ValueError("Invalid or expired token")
|
||||
)
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.post(
|
||||
"/auth/sync",
|
||||
json={"access_token": "invalid_token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Users Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestUsersEndpoint:
|
||||
"""Test GET /auth/users endpoint."""
|
||||
|
||||
def test_list_users_returns_list(self, client):
|
||||
"""List users should return paginated response."""
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.list_users = AsyncMock(return_value=([], 0))
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.get("/auth/users")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
|
||||
def test_list_users_with_search(self, client):
|
||||
"""List users should accept search parameter."""
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.list_users = AsyncMock(return_value=([], 0))
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.get("/auth/users?search=test")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_list_users_with_pagination(self, client):
|
||||
"""List users should accept pagination parameters."""
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.list_users = AsyncMock(return_value=([], 0))
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.get("/auth/users?offset=10&limit=20")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_list_users_limit_validation(self, client):
|
||||
"""List users should reject limit > 100."""
|
||||
response = client.get("/auth/users?limit=200")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Groups Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGroupsEndpoint:
|
||||
"""Test GET /auth/groups endpoint."""
|
||||
|
||||
def test_list_groups_returns_list(self, client):
|
||||
"""List groups should return paginated response."""
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.list_groups = AsyncMock(return_value=([], 0))
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.get("/auth/groups")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
|
||||
def test_list_groups_with_search(self, client):
|
||||
"""List groups should accept search parameter."""
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.list_groups = AsyncMock(return_value=([], 0))
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.get("/auth/groups?search=admin")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Roles Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestRolesEndpoint:
|
||||
"""Test GET /auth/roles endpoint."""
|
||||
|
||||
def test_list_roles_returns_list(self, client):
|
||||
"""List roles should return all roles."""
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.list_roles = AsyncMock(return_value=[])
|
||||
mock_instance.roles_to_schema = MagicMock(return_value=[])
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.get("/auth/roles")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Group-Role Assignment Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGroupRoleAssignmentEndpoints:
|
||||
"""Test group-role assignment and removal endpoints."""
|
||||
|
||||
def test_assign_role_to_group_success(self, client):
|
||||
"""POST /auth/groups/{id}/roles/{id} should assign role."""
|
||||
group_id = str(uuid.uuid4())
|
||||
role_id = str(uuid.uuid4())
|
||||
|
||||
mock_group = MagicMock()
|
||||
mock_group.id = uuid.UUID(group_id)
|
||||
mock_group.name = "Test Group"
|
||||
mock_group.roles = []
|
||||
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.assign_role_to_group = AsyncMock(return_value=mock_group)
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.post(f"/auth/groups/{group_id}/roles/{role_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["group_id"] == group_id
|
||||
assert data["group_name"] == "Test Group"
|
||||
assert "roles" in data
|
||||
|
||||
def test_assign_role_to_group_not_found(self, client):
|
||||
"""POST should return 404 when group not found."""
|
||||
group_id = str(uuid.uuid4())
|
||||
role_id = str(uuid.uuid4())
|
||||
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.assign_role_to_group = AsyncMock(
|
||||
side_effect=ValueError("Group not found")
|
||||
)
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.post(f"/auth/groups/{group_id}/roles/{role_id}")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_remove_role_from_group_success(self, client):
|
||||
"""DELETE /auth/groups/{id}/roles/{id} should remove role."""
|
||||
group_id = str(uuid.uuid4())
|
||||
role_id = str(uuid.uuid4())
|
||||
|
||||
mock_group = MagicMock()
|
||||
mock_group.id = uuid.UUID(group_id)
|
||||
mock_group.name = "Test Group"
|
||||
mock_group.roles = []
|
||||
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.remove_role_from_group = AsyncMock(return_value=mock_group)
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.delete(f"/auth/groups/{group_id}/roles/{role_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["group_id"] == group_id
|
||||
|
||||
def test_remove_role_from_group_not_found(self, client):
|
||||
"""DELETE should return 404 when role not found."""
|
||||
group_id = str(uuid.uuid4())
|
||||
role_id = str(uuid.uuid4())
|
||||
|
||||
with patch("src.domains.auth.controller.AuthService") as MockService:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.remove_role_from_group = AsyncMock(
|
||||
side_effect=ValueError("Role not found")
|
||||
)
|
||||
MockService.return_value = mock_instance
|
||||
|
||||
response = client.delete(f"/auth/groups/{group_id}/roles/{role_id}")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_assign_role_invalid_uuid(self, client):
|
||||
"""POST should return 422 for invalid UUID."""
|
||||
response = client.post("/auth/groups/not-a-uuid/roles/also-not-uuid")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Me Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestMeEndpoint:
|
||||
"""Test GET /auth/me endpoint."""
|
||||
|
||||
def test_me_not_implemented(self, client):
|
||||
"""Me endpoint should return 501 (not implemented yet)."""
|
||||
response = client.get("/auth/me")
|
||||
assert response.status_code == 501
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAuthSchemas:
|
||||
"""Test auth schema imports and structure."""
|
||||
|
||||
def test_all_schemas_importable(self):
|
||||
"""All auth schemas should be importable."""
|
||||
from src.domains.auth.schemas import (
|
||||
AuthSyncRequest,
|
||||
AuthSyncResponse,
|
||||
RoleSchema,
|
||||
UserSchema,
|
||||
UserPreferencesSchema,
|
||||
TokenInfoSchema,
|
||||
UserListItemSchema,
|
||||
UsersListResponse,
|
||||
BulkSyncResultSchema,
|
||||
GroupListItemSchema,
|
||||
GroupsListResponse,
|
||||
RolesListResponse,
|
||||
GroupRoleAssignmentResponse,
|
||||
)
|
||||
|
||||
assert AuthSyncRequest is not None
|
||||
assert AuthSyncResponse is not None
|
||||
assert RoleSchema is not None
|
||||
assert UserSchema is not None
|
||||
assert UserPreferencesSchema is not None
|
||||
assert TokenInfoSchema is not None
|
||||
assert UserListItemSchema is not None
|
||||
assert UsersListResponse is not None
|
||||
assert BulkSyncResultSchema is not None
|
||||
assert GroupListItemSchema is not None
|
||||
assert GroupsListResponse is not None
|
||||
assert RolesListResponse is not None
|
||||
assert GroupRoleAssignmentResponse is not None
|
||||
|
||||
def test_role_schema_includes_id(self):
|
||||
"""RoleSchema should include id field."""
|
||||
from src.domains.auth.schemas import RoleSchema
|
||||
|
||||
schema = RoleSchema(
|
||||
id=uuid.uuid4(),
|
||||
name="test.general:admin",
|
||||
domain="test",
|
||||
category="general",
|
||||
action="admin",
|
||||
)
|
||||
assert schema.id is not None
|
||||
|
||||
def test_role_schema_includes_category(self):
|
||||
"""RoleSchema should include category field."""
|
||||
from src.domains.auth.schemas import RoleSchema
|
||||
|
||||
schema = RoleSchema(
|
||||
id=uuid.uuid4(),
|
||||
name="test.specific:viewer",
|
||||
domain="test",
|
||||
category="specific",
|
||||
action="viewer",
|
||||
)
|
||||
assert schema.category == "specific"
|
||||
|
||||
def test_group_list_item_includes_roles(self):
|
||||
"""GroupListItemSchema should include roles list."""
|
||||
from src.domains.auth.schemas import GroupListItemSchema
|
||||
|
||||
schema = GroupListItemSchema(
|
||||
id=uuid.uuid4(),
|
||||
authentik_id=uuid.uuid4(),
|
||||
name="Test Group",
|
||||
is_superuser=False,
|
||||
parent_name=None,
|
||||
member_count=5,
|
||||
synced_at=datetime.now(timezone.utc),
|
||||
roles=["admin.general:admin", "media.general:viewer"],
|
||||
)
|
||||
assert len(schema.roles) == 2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Model Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAuthModels:
|
||||
"""Test auth model imports."""
|
||||
|
||||
def test_all_models_importable(self):
|
||||
"""All auth models should be importable."""
|
||||
from src.domains.auth.models import (
|
||||
User,
|
||||
Role,
|
||||
UserRole,
|
||||
Group,
|
||||
UserPreferences,
|
||||
ApiKey,
|
||||
user_groups,
|
||||
group_roles,
|
||||
)
|
||||
|
||||
assert User is not None
|
||||
assert Role is not None
|
||||
assert UserRole is not None
|
||||
assert Group is not None
|
||||
assert UserPreferences is not None
|
||||
assert ApiKey is not None
|
||||
assert user_groups is not None
|
||||
assert group_roles is not None
|
||||
|
||||
def test_role_model_has_category(self):
|
||||
"""Role model should have category attribute."""
|
||||
from src.domains.auth.models import Role
|
||||
|
||||
role = Role(
|
||||
name="test.general:admin",
|
||||
domain="test",
|
||||
category="general",
|
||||
action="admin",
|
||||
)
|
||||
assert role.category == "general"
|
||||
|
||||
def test_group_has_roles_relationship(self):
|
||||
"""Group model should have roles relationship."""
|
||||
from src.domains.auth.models import Group
|
||||
|
||||
assert hasattr(Group, "roles")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Permission System Tests (from oidc.py)
|
||||
# =============================================================================
|
||||
|
||||
class TestPermissionSystem:
|
||||
"""Test permission checking functions."""
|
||||
|
||||
def test_permission_constants_defined(self):
|
||||
"""Permission constants should be defined."""
|
||||
from src.domains.auth.oidc import (
|
||||
ACTION_HIERARCHY,
|
||||
VALID_DOMAINS,
|
||||
DEFAULT_CATEGORY,
|
||||
)
|
||||
|
||||
assert "viewer" in ACTION_HIERARCHY
|
||||
assert "user" in ACTION_HIERARCHY
|
||||
assert "editor" in ACTION_HIERARCHY
|
||||
assert "admin" in ACTION_HIERARCHY
|
||||
|
||||
assert "control-room" in VALID_DOMAINS
|
||||
assert "media" in VALID_DOMAINS
|
||||
assert "admin" in VALID_DOMAINS
|
||||
|
||||
assert DEFAULT_CATEGORY == "general"
|
||||
|
||||
def test_action_hierarchy_ordering(self):
|
||||
"""Action hierarchy should be ordered correctly."""
|
||||
from src.domains.auth.oidc import ACTION_HIERARCHY
|
||||
|
||||
assert ACTION_HIERARCHY["viewer"] < ACTION_HIERARCHY["user"]
|
||||
assert ACTION_HIERARCHY["user"] < ACTION_HIERARCHY["editor"]
|
||||
assert ACTION_HIERARCHY["editor"] < ACTION_HIERARCHY["admin"]
|
||||
|
||||
def test_require_permission_importable(self):
|
||||
"""require_permission should be importable."""
|
||||
from src.domains.auth.oidc import require_permission, require_any_permission
|
||||
|
||||
assert callable(require_permission)
|
||||
assert callable(require_any_permission)
|
||||
|
||||
def test_require_permission_returns_dependency(self):
|
||||
"""require_permission should return a callable dependency."""
|
||||
from src.domains.auth.oidc import require_permission
|
||||
|
||||
dependency = require_permission("control-room", "admin")
|
||||
assert callable(dependency)
|
||||
|
||||
def test_require_any_permission_returns_dependency(self):
|
||||
"""require_any_permission should return a callable dependency."""
|
||||
from src.domains.auth.oidc import require_any_permission
|
||||
|
||||
dependency = require_any_permission(
|
||||
("control-room", "admin"),
|
||||
("media", "editor"),
|
||||
)
|
||||
assert callable(dependency)
|
||||
@@ -0,0 +1,627 @@
|
||||
"""Tests for authentication service."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.models import User, Role, Group, UserPreferences
|
||||
from src.domains.auth.schemas import TokenInfoSchema, RoleSchema
|
||||
from src.domains.auth.service import AuthService, get_auth_service
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""Create a mock async database session."""
|
||||
session = AsyncMock(spec=AsyncSession)
|
||||
session.execute = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.flush = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_service(mock_session):
|
||||
"""Create an AuthService instance with mock session."""
|
||||
return AuthService(mock_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user():
|
||||
"""Create a sample user for testing."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
authentik_id=uuid.uuid4(),
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
avatar_url="https://example.com/avatar.jpg",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_login=datetime.now(timezone.utc),
|
||||
)
|
||||
user.roles = []
|
||||
user.preferences = None
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_role():
|
||||
"""Create a sample role for testing."""
|
||||
return Role(
|
||||
id=uuid.uuid4(),
|
||||
name="control-room.general:admin",
|
||||
domain="control-room",
|
||||
category="general",
|
||||
action="admin",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_group(sample_role):
|
||||
"""Create a sample group for testing."""
|
||||
group = Group(
|
||||
id=uuid.uuid4(),
|
||||
authentik_id=uuid.uuid4(),
|
||||
name="Administrators",
|
||||
is_superuser=True,
|
||||
parent_name=None,
|
||||
member_count=5,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
synced_at=datetime.now(timezone.utc),
|
||||
)
|
||||
group.roles = [sample_role]
|
||||
return group
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_token_info():
|
||||
"""Create sample token info from Authentik."""
|
||||
return TokenInfoSchema(
|
||||
sub=str(uuid.uuid4()),
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
preferred_username="testuser",
|
||||
groups=["Administrators", "Developers"],
|
||||
picture="https://example.com/avatar.jpg",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_preferences():
|
||||
"""Create sample user preferences."""
|
||||
return UserPreferences(
|
||||
user_id=uuid.uuid4(),
|
||||
theme="dark",
|
||||
default_room="control-room",
|
||||
preferences_json={"notifications": True},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AuthService Initialization Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAuthServiceInit:
|
||||
"""Test AuthService initialization."""
|
||||
|
||||
def test_init_with_session(self, mock_session):
|
||||
"""AuthService should initialize with session."""
|
||||
service = AuthService(mock_session)
|
||||
assert service.session is mock_session
|
||||
|
||||
def test_init_sets_userinfo_url(self, mock_session):
|
||||
"""AuthService should set userinfo URL from settings."""
|
||||
service = AuthService(mock_session)
|
||||
assert "userinfo" in service.userinfo_url
|
||||
|
||||
def test_get_auth_service_factory(self, mock_session):
|
||||
"""get_auth_service should return AuthService instance."""
|
||||
service = get_auth_service(mock_session)
|
||||
assert isinstance(service, AuthService)
|
||||
assert service.session is mock_session
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Token Validation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestValidateToken:
|
||||
"""Test token validation via Authentik userinfo endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_token_success(self, auth_service):
|
||||
"""validate_token should return token info on success."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"sub": str(uuid.uuid4()),
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"preferred_username": "testuser",
|
||||
"groups": ["Administrators"],
|
||||
"picture": "https://example.com/avatar.jpg",
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("src.domains.auth.service.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
result = await auth_service.validate_token("valid_token")
|
||||
|
||||
assert result.email == "test@example.com"
|
||||
assert result.name == "Test User"
|
||||
assert "Administrators" in result.groups
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_token_invalid(self, auth_service):
|
||||
"""validate_token should raise ValueError for invalid token."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
with patch("src.domains.auth.service.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid or expired token"):
|
||||
await auth_service.validate_token("invalid_token")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_token_service_unavailable(self, auth_service):
|
||||
"""validate_token should raise ValueError when service unavailable."""
|
||||
import httpx
|
||||
|
||||
with patch("src.domains.auth.service.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
side_effect=httpx.RequestError("Connection failed")
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Authentication service unavailable"):
|
||||
await auth_service.validate_token("token")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Sync Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSyncUser:
|
||||
"""Test user synchronization from OIDC token."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_user_creates_new_user(self, auth_service, sample_token_info, mock_session):
|
||||
"""sync_user should create new user when not found."""
|
||||
# Mock no existing user found
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
user, is_new = await auth_service.sync_user(sample_token_info)
|
||||
|
||||
assert is_new is True
|
||||
assert mock_session.add.call_count == 2 # User and Preferences
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_user_updates_existing_user(
|
||||
self, auth_service, sample_token_info, sample_user, mock_session
|
||||
):
|
||||
"""sync_user should update existing user when found."""
|
||||
# Mock existing user found
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = sample_user
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
# Update token info with matching authentik_id
|
||||
sample_token_info.sub = str(sample_user.authentik_id)
|
||||
|
||||
user, is_new = await auth_service.sync_user(sample_token_info)
|
||||
|
||||
assert is_new is False
|
||||
assert user.email == sample_token_info.email
|
||||
assert user.name == sample_token_info.name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_user_updates_last_login(
|
||||
self, auth_service, sample_token_info, sample_user, mock_session
|
||||
):
|
||||
"""sync_user should update last_login timestamp."""
|
||||
old_login = sample_user.last_login
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = sample_user
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
sample_token_info.sub = str(sample_user.authentik_id)
|
||||
|
||||
user, _ = await auth_service.sync_user(sample_token_info)
|
||||
|
||||
assert user.last_login is not None
|
||||
# last_login should be updated (or same if happened in same second)
|
||||
assert user.last_login >= old_login or user.last_login is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Role Sync Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSyncRoles:
|
||||
"""Test role synchronization from Authentik groups via group_roles."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_roles_from_groups(
|
||||
self, auth_service, sample_user, sample_group, mock_session
|
||||
):
|
||||
"""sync_roles should get roles from matching groups."""
|
||||
# Mock finding groups with roles
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [sample_group]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
roles = await auth_service.sync_roles(sample_user, ["Administrators"])
|
||||
|
||||
assert len(roles) == 1
|
||||
assert roles[0].name == "control-room.general:admin"
|
||||
assert sample_user.roles == roles
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_roles_no_matching_groups(
|
||||
self, auth_service, sample_user, mock_session
|
||||
):
|
||||
"""sync_roles should return empty list when no groups match."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
roles = await auth_service.sync_roles(sample_user, ["NonExistentGroup"])
|
||||
|
||||
assert len(roles) == 0
|
||||
assert sample_user.roles == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_roles_deduplicates_roles(
|
||||
self, auth_service, sample_user, sample_role, mock_session
|
||||
):
|
||||
"""sync_roles should deduplicate roles from multiple groups."""
|
||||
# Create two groups with the same role
|
||||
group1 = Group(
|
||||
id=uuid.uuid4(),
|
||||
authentik_id=uuid.uuid4(),
|
||||
name="Group1",
|
||||
is_superuser=False,
|
||||
member_count=1,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
synced_at=datetime.now(timezone.utc),
|
||||
)
|
||||
group1.roles = [sample_role]
|
||||
|
||||
group2 = Group(
|
||||
id=uuid.uuid4(),
|
||||
authentik_id=uuid.uuid4(),
|
||||
name="Group2",
|
||||
is_superuser=False,
|
||||
member_count=1,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
synced_at=datetime.now(timezone.utc),
|
||||
)
|
||||
group2.roles = [sample_role] # Same role
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [group1, group2]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
roles = await auth_service.sync_roles(sample_user, ["Group1", "Group2"])
|
||||
|
||||
# Should only have one role despite appearing in two groups
|
||||
assert len(roles) == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Conversion Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSchemaConversions:
|
||||
"""Test model to schema conversions."""
|
||||
|
||||
def test_user_to_schema(self, auth_service, sample_user):
|
||||
"""user_to_schema should convert User model to UserSchema."""
|
||||
schema = auth_service.user_to_schema(sample_user)
|
||||
|
||||
assert schema.id == sample_user.id
|
||||
assert schema.authentik_id == sample_user.authentik_id
|
||||
assert schema.email == sample_user.email
|
||||
assert schema.name == sample_user.name
|
||||
assert schema.avatar_url == sample_user.avatar_url
|
||||
|
||||
def test_roles_to_schema(self, auth_service, sample_role):
|
||||
"""roles_to_schema should convert Role models to RoleSchemas."""
|
||||
schemas = auth_service.roles_to_schema([sample_role])
|
||||
|
||||
assert len(schemas) == 1
|
||||
assert schemas[0].id == sample_role.id
|
||||
assert schemas[0].name == sample_role.name
|
||||
assert schemas[0].domain == sample_role.domain
|
||||
assert schemas[0].category == sample_role.category
|
||||
assert schemas[0].action == sample_role.action
|
||||
|
||||
def test_roles_to_schema_empty_list(self, auth_service):
|
||||
"""roles_to_schema should handle empty list."""
|
||||
schemas = auth_service.roles_to_schema([])
|
||||
assert schemas == []
|
||||
|
||||
def test_preferences_to_schema(self, auth_service, sample_preferences):
|
||||
"""preferences_to_schema should convert UserPreferences to schema."""
|
||||
schema = auth_service.preferences_to_schema(sample_preferences)
|
||||
|
||||
assert schema.theme == sample_preferences.theme
|
||||
assert schema.default_room == sample_preferences.default_room
|
||||
assert schema.preferences_json == sample_preferences.preferences_json
|
||||
|
||||
def test_preferences_to_schema_none(self, auth_service):
|
||||
"""preferences_to_schema should return defaults for None."""
|
||||
schema = auth_service.preferences_to_schema(None)
|
||||
|
||||
assert schema.theme == "system"
|
||||
assert schema.default_room == "front-hall"
|
||||
assert schema.preferences_json == {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# List Operations Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestListOperations:
|
||||
"""Test list operations for users, groups, and roles."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(self, auth_service, sample_user, mock_session):
|
||||
"""list_users should return paginated user list."""
|
||||
sample_user.roles = []
|
||||
|
||||
# Mock count query
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
# Mock users query
|
||||
users_result = MagicMock()
|
||||
users_result.scalars.return_value.all.return_value = [sample_user]
|
||||
|
||||
mock_session.execute.side_effect = [count_result, users_result]
|
||||
|
||||
items, total = await auth_service.list_users()
|
||||
|
||||
assert total == 1
|
||||
assert len(items) == 1
|
||||
assert items[0].email == sample_user.email
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_with_search(self, auth_service, mock_session):
|
||||
"""list_users should filter by search query."""
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 0
|
||||
|
||||
users_result = MagicMock()
|
||||
users_result.scalars.return_value.all.return_value = []
|
||||
|
||||
mock_session.execute.side_effect = [count_result, users_result]
|
||||
|
||||
items, total = await auth_service.list_users(search="nonexistent")
|
||||
|
||||
assert total == 0
|
||||
assert len(items) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_groups(self, auth_service, sample_group, mock_session):
|
||||
"""list_groups should return paginated group list with roles."""
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
groups_result = MagicMock()
|
||||
groups_result.scalars.return_value.all.return_value = [sample_group]
|
||||
|
||||
mock_session.execute.side_effect = [count_result, groups_result]
|
||||
|
||||
items, total = await auth_service.list_groups()
|
||||
|
||||
assert total == 1
|
||||
assert len(items) == 1
|
||||
assert items[0].name == sample_group.name
|
||||
assert len(items[0].roles) == 1 # Should include role names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_roles(self, auth_service, sample_role, mock_session):
|
||||
"""list_roles should return all roles ordered by domain."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [sample_role]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
roles = await auth_service.list_roles()
|
||||
|
||||
assert len(roles) == 1
|
||||
assert roles[0].name == sample_role.name
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Group-Role Management Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGroupRoleManagement:
|
||||
"""Test group-role assignment and removal."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_by_id(self, auth_service, sample_group, mock_session):
|
||||
"""get_group_by_id should return group with roles."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = sample_group
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
group = await auth_service.get_group_by_id(sample_group.id)
|
||||
|
||||
assert group is not None
|
||||
assert group.id == sample_group.id
|
||||
assert len(group.roles) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_by_id_not_found(self, auth_service, mock_session):
|
||||
"""get_group_by_id should return None when not found."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
group = await auth_service.get_group_by_id(uuid.uuid4())
|
||||
|
||||
assert group is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_role_to_group(
|
||||
self, auth_service, sample_group, sample_role, mock_session
|
||||
):
|
||||
"""assign_role_to_group should add role to group."""
|
||||
# Clear existing roles for this test
|
||||
sample_group.roles = []
|
||||
|
||||
# Mock group lookup
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
# Mock role lookup
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = sample_role
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
group = await auth_service.assign_role_to_group(sample_group.id, sample_role.id)
|
||||
|
||||
assert sample_role in group.roles
|
||||
mock_session.flush.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_role_to_group_already_assigned(
|
||||
self, auth_service, sample_group, sample_role, mock_session
|
||||
):
|
||||
"""assign_role_to_group should not duplicate if already assigned."""
|
||||
# Group already has this role
|
||||
sample_group.roles = [sample_role]
|
||||
original_count = len(sample_group.roles)
|
||||
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = sample_role
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
group = await auth_service.assign_role_to_group(sample_group.id, sample_role.id)
|
||||
|
||||
assert len(group.roles) == original_count # No duplicate
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_role_to_group_group_not_found(self, auth_service, mock_session):
|
||||
"""assign_role_to_group should raise ValueError when group not found."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with pytest.raises(ValueError, match="Group not found"):
|
||||
await auth_service.assign_role_to_group(uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_role_to_group_role_not_found(
|
||||
self, auth_service, sample_group, mock_session
|
||||
):
|
||||
"""assign_role_to_group should raise ValueError when role not found."""
|
||||
sample_group.roles = []
|
||||
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = None
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
with pytest.raises(ValueError, match="Role not found"):
|
||||
await auth_service.assign_role_to_group(sample_group.id, uuid.uuid4())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_role_from_group(
|
||||
self, auth_service, sample_group, sample_role, mock_session
|
||||
):
|
||||
"""remove_role_from_group should remove role from group."""
|
||||
# Group has this role
|
||||
sample_group.roles = [sample_role]
|
||||
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = sample_role
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
group = await auth_service.remove_role_from_group(sample_group.id, sample_role.id)
|
||||
|
||||
assert sample_role not in group.roles
|
||||
mock_session.flush.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_role_from_group_not_assigned(
|
||||
self, auth_service, sample_group, sample_role, mock_session
|
||||
):
|
||||
"""remove_role_from_group should handle role not assigned gracefully."""
|
||||
# Group does not have this role
|
||||
sample_group.roles = []
|
||||
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = sample_role
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
group = await auth_service.remove_role_from_group(sample_group.id, sample_role.id)
|
||||
|
||||
# Should complete without error
|
||||
assert len(group.roles) == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Role Schema Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestRoleSchema:
|
||||
"""Test RoleSchema validation."""
|
||||
|
||||
def test_role_schema_creation(self):
|
||||
"""RoleSchema should be creatable with valid data."""
|
||||
schema = RoleSchema(
|
||||
id=uuid.uuid4(),
|
||||
name="control-room.general:admin",
|
||||
domain="control-room",
|
||||
category="general",
|
||||
action="admin",
|
||||
)
|
||||
|
||||
assert schema.name == "control-room.general:admin"
|
||||
assert schema.domain == "control-room"
|
||||
assert schema.category == "general"
|
||||
assert schema.action == "admin"
|
||||
|
||||
def test_role_schema_category_default(self):
|
||||
"""RoleSchema should default category to 'general'."""
|
||||
schema = RoleSchema(
|
||||
id=uuid.uuid4(),
|
||||
name="media.general:viewer",
|
||||
domain="media",
|
||||
action="viewer",
|
||||
)
|
||||
|
||||
assert schema.category == "general"
|
||||
Reference in New Issue
Block a user