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>
518 lines
19 KiB
Python
518 lines
19 KiB
Python
"""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)
|