Add comprehensive tests for NPM forward auth endpoint: - Forward auth header parsing - User lookup methods (get_user_by_email, get_user_by_authentik_id) - Admin gate authorization logic - OpenAPI spec validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1017 lines
37 KiB
Python
1017 lines
37 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
|
|
|
|
|
|
# =============================================================================
|
|
# 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)
|
|
|
|
|
|
# =============================================================================
|
|
# Phase 4: User Profile Endpoint Tests
|
|
# =============================================================================
|
|
|
|
class TestUserProfileEndpoint:
|
|
"""Test GET /auth/users/me endpoint."""
|
|
|
|
def test_users_me_in_openapi(self, client):
|
|
"""Users me endpoint should be in OpenAPI spec."""
|
|
response = client.get("/openapi.json")
|
|
spec = response.json()
|
|
assert "/auth/users/me" in spec["paths"]
|
|
assert "get" in spec["paths"]["/auth/users/me"]
|
|
|
|
def test_users_me_requires_auth(self, client):
|
|
"""Users me should return 401 without auth."""
|
|
response = client.get("/auth/users/me")
|
|
# Without proper auth setup, should fail
|
|
assert response.status_code in [401, 403, 500]
|
|
|
|
def test_users_me_returns_profile(self, client):
|
|
"""Users me should return user profile with roles and preferences."""
|
|
user_id = uuid.uuid4()
|
|
authentik_id = uuid.uuid4()
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.id = user_id
|
|
mock_user.authentik_id = authentik_id
|
|
mock_user.email = "test@example.com"
|
|
mock_user.name = "Test User"
|
|
mock_user.avatar_url = None
|
|
mock_user.created_at = datetime.now(timezone.utc)
|
|
mock_user.last_login = None
|
|
mock_user.roles = []
|
|
|
|
mock_preferences = MagicMock()
|
|
mock_preferences.theme = "system"
|
|
mock_preferences.default_room = "front-hall"
|
|
mock_preferences.preferences_json = {}
|
|
|
|
with patch("src.domains.auth.controller.get_current_user") as mock_get_user:
|
|
mock_get_user.return_value = mock_user
|
|
with patch("src.domains.auth.controller.AuthService") as MockService:
|
|
mock_instance = MagicMock()
|
|
mock_instance.get_user_preferences = AsyncMock(return_value=mock_preferences)
|
|
MockService.return_value = mock_instance
|
|
|
|
# Override the dependency
|
|
from src.domains.auth.controller import get_current_user
|
|
app.dependency_overrides[get_current_user] = lambda: mock_user
|
|
|
|
try:
|
|
response = client.get("/auth/users/me")
|
|
# Note: May still fail due to complex auth flow
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
assert "user" in data
|
|
assert "roles" in data
|
|
assert "preferences" in data
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# =============================================================================
|
|
# Phase 4: Preferences Endpoint Tests
|
|
# =============================================================================
|
|
|
|
class TestPreferencesEndpoints:
|
|
"""Test /auth/users/me/preferences endpoints."""
|
|
|
|
def test_preferences_get_in_openapi(self, client):
|
|
"""Preferences GET endpoint should be in OpenAPI spec."""
|
|
response = client.get("/openapi.json")
|
|
spec = response.json()
|
|
assert "/auth/users/me/preferences" in spec["paths"]
|
|
assert "get" in spec["paths"]["/auth/users/me/preferences"]
|
|
|
|
def test_preferences_patch_in_openapi(self, client):
|
|
"""Preferences PATCH endpoint should be in OpenAPI spec."""
|
|
response = client.get("/openapi.json")
|
|
spec = response.json()
|
|
assert "/auth/users/me/preferences" in spec["paths"]
|
|
assert "patch" in spec["paths"]["/auth/users/me/preferences"]
|
|
|
|
def test_preferences_requires_auth(self, client):
|
|
"""Preferences endpoints should require auth."""
|
|
response = client.get("/auth/users/me/preferences")
|
|
assert response.status_code in [401, 403, 500]
|
|
|
|
response = client.patch("/auth/users/me/preferences", json={"theme": "dark"})
|
|
assert response.status_code in [401, 403, 422, 500]
|
|
|
|
|
|
# =============================================================================
|
|
# Phase 4: API Keys Endpoint Tests
|
|
# =============================================================================
|
|
|
|
class TestApiKeysEndpoints:
|
|
"""Test /auth/users/me/api-keys endpoints."""
|
|
|
|
def test_api_keys_list_in_openapi(self, client):
|
|
"""API keys list endpoint should be in OpenAPI spec."""
|
|
response = client.get("/openapi.json")
|
|
spec = response.json()
|
|
assert "/auth/users/me/api-keys" in spec["paths"]
|
|
assert "get" in spec["paths"]["/auth/users/me/api-keys"]
|
|
|
|
def test_api_keys_create_in_openapi(self, client):
|
|
"""API keys create endpoint should be in OpenAPI spec."""
|
|
response = client.get("/openapi.json")
|
|
spec = response.json()
|
|
assert "/auth/users/me/api-keys" in spec["paths"]
|
|
assert "post" in spec["paths"]["/auth/users/me/api-keys"]
|
|
|
|
def test_api_keys_delete_in_openapi(self, client):
|
|
"""API keys delete endpoint should be in OpenAPI spec."""
|
|
response = client.get("/openapi.json")
|
|
spec = response.json()
|
|
assert "/auth/users/me/api-keys/{key_id}" in spec["paths"]
|
|
assert "delete" in spec["paths"]["/auth/users/me/api-keys/{key_id}"]
|
|
|
|
def test_api_keys_requires_auth(self, client):
|
|
"""API keys endpoints should require auth."""
|
|
response = client.get("/auth/users/me/api-keys")
|
|
assert response.status_code in [401, 403, 500]
|
|
|
|
def test_api_keys_create_requires_name(self, client):
|
|
"""API key creation should require name."""
|
|
# Even without auth, should validate request body
|
|
response = client.post("/auth/users/me/api-keys", json={})
|
|
assert response.status_code in [401, 403, 422, 500]
|
|
|
|
def test_api_keys_delete_invalid_uuid(self, client):
|
|
"""API key delete should validate UUID."""
|
|
response = client.delete("/auth/users/me/api-keys/not-a-uuid")
|
|
assert response.status_code == 422
|
|
|
|
|
|
# =============================================================================
|
|
# Phase 4: Schema Tests
|
|
# =============================================================================
|
|
|
|
class TestPhase4Schemas:
|
|
"""Test Phase 4 schema imports and structure."""
|
|
|
|
def test_phase4_schemas_importable(self):
|
|
"""Phase 4 schemas should be importable."""
|
|
from src.domains.auth.schemas import (
|
|
UserProfileResponse,
|
|
PreferencesUpdateRequest,
|
|
ApiKeyCreateRequest,
|
|
ApiKeyCreateResponse,
|
|
ApiKeySchema,
|
|
ApiKeysListResponse,
|
|
)
|
|
|
|
assert UserProfileResponse is not None
|
|
assert PreferencesUpdateRequest is not None
|
|
assert ApiKeyCreateRequest is not None
|
|
assert ApiKeyCreateResponse is not None
|
|
assert ApiKeySchema is not None
|
|
assert ApiKeysListResponse is not None
|
|
|
|
def test_user_profile_response_structure(self):
|
|
"""UserProfileResponse should have user, roles, and preferences."""
|
|
from src.domains.auth.schemas import (
|
|
UserProfileResponse,
|
|
UserSchema,
|
|
RoleSchema,
|
|
UserPreferencesSchema,
|
|
)
|
|
|
|
user = UserSchema(
|
|
id=uuid.uuid4(),
|
|
authentik_id=uuid.uuid4(),
|
|
email="test@example.com",
|
|
name="Test User",
|
|
avatar_url=None,
|
|
created_at=datetime.now(timezone.utc),
|
|
last_login=None,
|
|
)
|
|
role = RoleSchema(
|
|
id=uuid.uuid4(),
|
|
name="test.general:admin",
|
|
domain="test",
|
|
category="general",
|
|
action="admin",
|
|
)
|
|
prefs = UserPreferencesSchema(
|
|
theme="dark",
|
|
default_room="kitchen",
|
|
preferences_json={"foo": "bar"},
|
|
)
|
|
|
|
response = UserProfileResponse(
|
|
user=user,
|
|
roles=[role],
|
|
preferences=prefs,
|
|
)
|
|
|
|
assert response.user.email == "test@example.com"
|
|
assert len(response.roles) == 1
|
|
assert response.preferences.theme == "dark"
|
|
|
|
def test_preferences_update_request_optional_fields(self):
|
|
"""PreferencesUpdateRequest should accept partial updates."""
|
|
from src.domains.auth.schemas import PreferencesUpdateRequest
|
|
|
|
# All fields optional
|
|
request = PreferencesUpdateRequest()
|
|
assert request.theme is None
|
|
assert request.default_room is None
|
|
assert request.preferences_json is None
|
|
|
|
# Partial update
|
|
request = PreferencesUpdateRequest(theme="dark")
|
|
assert request.theme == "dark"
|
|
assert request.default_room is None
|
|
|
|
def test_api_key_create_request_validation(self):
|
|
"""ApiKeyCreateRequest should validate fields."""
|
|
from src.domains.auth.schemas import ApiKeyCreateRequest
|
|
import pydantic
|
|
|
|
# Name required
|
|
with pytest.raises(pydantic.ValidationError):
|
|
ApiKeyCreateRequest()
|
|
|
|
# Valid request
|
|
request = ApiKeyCreateRequest(name="My Key")
|
|
assert request.name == "My Key"
|
|
assert request.scopes is None
|
|
assert request.expires_in_days is None
|
|
|
|
# With optional fields
|
|
request = ApiKeyCreateRequest(
|
|
name="My Key",
|
|
scopes=["media.general:viewer"],
|
|
expires_in_days=30,
|
|
)
|
|
assert request.scopes == ["media.general:viewer"]
|
|
assert request.expires_in_days == 30
|
|
|
|
def test_api_key_create_response_includes_key(self):
|
|
"""ApiKeyCreateResponse should include the actual key."""
|
|
from src.domains.auth.schemas import ApiKeyCreateResponse
|
|
|
|
response = ApiKeyCreateResponse(
|
|
id=uuid.uuid4(),
|
|
name="Test Key",
|
|
key="tak_abc123def456ghi789",
|
|
key_prefix="tak_abc1",
|
|
scopes=None,
|
|
expires_at=None,
|
|
created_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
assert response.key.startswith("tak_")
|
|
assert response.key_prefix == "tak_abc1"
|
|
|
|
def test_api_key_schema_has_is_expired(self):
|
|
"""ApiKeySchema should have is_expired field."""
|
|
from src.domains.auth.schemas import ApiKeySchema
|
|
|
|
# Not expired
|
|
schema = ApiKeySchema(
|
|
id=uuid.uuid4(),
|
|
name="Test Key",
|
|
key_prefix="tak_abc1",
|
|
scopes=None,
|
|
expires_at=None,
|
|
last_used_at=None,
|
|
created_at=datetime.now(timezone.utc),
|
|
is_expired=False,
|
|
)
|
|
assert schema.is_expired is False
|
|
|
|
# Expired
|
|
schema = ApiKeySchema(
|
|
id=uuid.uuid4(),
|
|
name="Test Key",
|
|
key_prefix="tak_abc1",
|
|
scopes=None,
|
|
expires_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
|
|
last_used_at=None,
|
|
created_at=datetime.now(timezone.utc),
|
|
is_expired=True,
|
|
)
|
|
assert schema.is_expired is True
|
|
|
|
def test_api_keys_list_response_structure(self):
|
|
"""ApiKeysListResponse should have items and total."""
|
|
from src.domains.auth.schemas import ApiKeysListResponse, ApiKeySchema
|
|
|
|
key = ApiKeySchema(
|
|
id=uuid.uuid4(),
|
|
name="Test Key",
|
|
key_prefix="tak_abc1",
|
|
scopes=None,
|
|
expires_at=None,
|
|
last_used_at=None,
|
|
created_at=datetime.now(timezone.utc),
|
|
is_expired=False,
|
|
)
|
|
|
|
response = ApiKeysListResponse(items=[key], total=1)
|
|
assert len(response.items) == 1
|
|
assert response.total == 1
|
|
|
|
|
|
# =============================================================================
|
|
# GET /auth/me Endpoint Tests (NPM Forward Auth)
|
|
# =============================================================================
|
|
|
|
class TestAuthMeEndpoint:
|
|
"""Test GET /auth/me endpoint with NPM forward auth."""
|
|
|
|
def test_auth_me_in_openapi(self, client):
|
|
"""Auth me endpoint should be in OpenAPI spec."""
|
|
response = client.get("/openapi.json")
|
|
spec = response.json()
|
|
assert "/auth/me" in spec["paths"]
|
|
assert "get" in spec["paths"]["/auth/me"]
|
|
|
|
def test_auth_me_returns_401_without_forward_auth(self, client):
|
|
"""Should return 401 when accessed without forward auth headers."""
|
|
response = client.get("/auth/me")
|
|
# Without NPM forward auth headers, should return 401
|
|
assert response.status_code == 401
|
|
|
|
def test_auth_me_response_schema(self, client):
|
|
"""Auth me should return AuthSyncResponse schema."""
|
|
response = client.get("/openapi.json")
|
|
spec = response.json()
|
|
|
|
# Check response schema references AuthSyncResponse
|
|
me_endpoint = spec["paths"]["/auth/me"]["get"]
|
|
assert "responses" in me_endpoint
|
|
assert "200" in me_endpoint["responses"]
|
|
|
|
|
|
class TestForwardAuthParsing:
|
|
"""Test NPM forward auth header parsing."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parses_all_headers(self):
|
|
"""Should parse all X-authentik-* headers."""
|
|
from src.auth.oidc import get_forward_auth_user
|
|
|
|
mock_request = MagicMock()
|
|
headers = {
|
|
"x-authentik-username": "jdoe",
|
|
"x-authentik-email": "john.doe@example.com",
|
|
"x-authentik-groups": "tatlock-admins, tatlock-media-viewers",
|
|
"x-authentik-name": "John Doe",
|
|
"x-authentik-uid": "550e8400-e29b-41d4-a716-446655440000",
|
|
}
|
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
|
|
|
result = await get_forward_auth_user(mock_request)
|
|
|
|
assert result["username"] == "jdoe"
|
|
assert result["email"] == "john.doe@example.com"
|
|
assert result["name"] == "John Doe"
|
|
assert result["uid"] == "550e8400-e29b-41d4-a716-446655440000"
|
|
assert "tatlock-admins" in result["groups"]
|
|
assert "tatlock-media-viewers" in result["groups"]
|
|
assert result["auth_method"] == "forward_auth"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_for_internal_access(self):
|
|
"""Should return None when no forward auth headers (internal access)."""
|
|
from src.auth.oidc import get_forward_auth_user
|
|
|
|
mock_request = MagicMock()
|
|
mock_request.headers.get.return_value = None
|
|
|
|
result = await get_forward_auth_user(mock_request)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raises_401_missing_email(self):
|
|
"""Should raise 401 when username present but email missing."""
|
|
from src.auth.oidc import get_forward_auth_user
|
|
from fastapi import HTTPException
|
|
|
|
mock_request = MagicMock()
|
|
headers = {
|
|
"x-authentik-username": "jdoe",
|
|
"x-authentik-email": None,
|
|
}
|
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await get_forward_auth_user(mock_request)
|
|
|
|
assert exc.value.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handles_empty_groups(self):
|
|
"""Should handle empty groups header."""
|
|
from src.auth.oidc import get_forward_auth_user
|
|
|
|
mock_request = MagicMock()
|
|
headers = {
|
|
"x-authentik-username": "jdoe",
|
|
"x-authentik-email": "jdoe@example.com",
|
|
"x-authentik-groups": "",
|
|
"x-authentik-name": None,
|
|
"x-authentik-uid": None,
|
|
}
|
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
|
|
|
result = await get_forward_auth_user(mock_request)
|
|
|
|
assert result["groups"] == [""]
|
|
assert result["name"] == "jdoe" # Falls back to username
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strips_whitespace_from_groups(self):
|
|
"""Should strip whitespace from group names."""
|
|
from src.auth.oidc import get_forward_auth_user
|
|
|
|
mock_request = MagicMock()
|
|
headers = {
|
|
"x-authentik-username": "jdoe",
|
|
"x-authentik-email": "jdoe@example.com",
|
|
"x-authentik-groups": " group1 , group2 ,group3",
|
|
"x-authentik-name": "John",
|
|
"x-authentik-uid": None,
|
|
}
|
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
|
|
|
result = await get_forward_auth_user(mock_request)
|
|
|
|
assert result["groups"] == ["group1", "group2", "group3"]
|
|
|
|
|
|
class TestAuthServiceNewMethods:
|
|
"""Test new AuthService methods for /auth/me."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_by_email(self):
|
|
"""Should find user by email."""
|
|
from src.auth.service import AuthService
|
|
|
|
mock_session = AsyncMock()
|
|
mock_user = MagicMock()
|
|
mock_user.email = "test@example.com"
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_user
|
|
mock_session.execute.return_value = mock_result
|
|
|
|
service = AuthService(mock_session)
|
|
result = await service.get_user_by_email("test@example.com")
|
|
|
|
assert result is not None
|
|
assert result.email == "test@example.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_by_email_not_found(self):
|
|
"""Should return None when user not found."""
|
|
from src.auth.service import AuthService
|
|
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
mock_session.execute.return_value = mock_result
|
|
|
|
service = AuthService(mock_session)
|
|
result = await service.get_user_by_email("notfound@example.com")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_by_authentik_id(self):
|
|
"""Should find user by Authentik UUID."""
|
|
from src.auth.service import AuthService
|
|
|
|
mock_session = AsyncMock()
|
|
test_id = uuid.uuid4()
|
|
mock_user = MagicMock()
|
|
mock_user.authentik_id = test_id
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_user
|
|
mock_session.execute.return_value = mock_result
|
|
|
|
service = AuthService(mock_session)
|
|
result = await service.get_user_by_authentik_id(test_id)
|
|
|
|
assert result is not None
|
|
assert result.authentik_id == test_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_by_authentik_id_not_found(self):
|
|
"""Should return None when user not found by Authentik ID."""
|
|
from src.auth.service import AuthService
|
|
|
|
mock_session = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
mock_session.execute.return_value = mock_result
|
|
|
|
service = AuthService(mock_session)
|
|
result = await service.get_user_by_authentik_id(uuid.uuid4())
|
|
|
|
assert result is None
|