Files
core-api/tests/test_auth_controller.py
T
jpmschweitzerandClaude 01349a83f2 test: repair the suite against the current API
The suite could not even collect: the venv was missing declared dependencies,
and five tests asserted an API that had moved on. 11 collection errors to 381
passing.

test_oidc.py was written for the single-issuer API and 6243f29 replaced it.
issuer and audience became lists, jwks_uri stopped being an attribute in
favour of get_jwks_uri(issuer), get_jwks became get_jwks_for_issuer, and the
lru_cache became a per-issuer dict so cache_clear no longer exists. Rewritten
against the current surface, with coverage added for the two behaviours the
multi-issuer change introduced and never tested: is_valid_issuer rejecting an
unconfigured issuer, and the cache keying per issuer. Both are
security-relevant — a shared cache would serve one issuer keys for another.

Three /auth/me tests asserted a path that does not exist. The route is
declared as /me inside AuthController.create_router() and mounts at
/auth/users/me; the generated spec is authoritative and the local app and the
deployed service agree on it. Those tests had never passed.

test_handles_empty_groups expected groups == [""] for an empty header. oidc.py
has returned [] since the initial commit, and [] is correct — [""] would also
be unsafe, since any check doing "" in groups would match.

test_model_aliases_property covered Settings.model_aliases, deleted with the
Ollama integration in c1f16d4. Removed rather than repaired.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:21:43 +02:00

1031 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/users/me Endpoint Tests (NPM Forward Auth)
# =============================================================================
class TestAuthMeEndpoint:
"""
Test GET /auth/users/me with NPM forward auth.
The path is /auth/users/me, not /auth/me — the route is declared as "/me"
inside AuthController.create_router(), which mounts under a users prefix.
These tests asserted /auth/me and had never passed; the generated spec is
authoritative and both the local app and the deployed service agree on 62
paths including this one.
"""
PATH = "/auth/users/me"
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 self.PATH in spec["paths"]
assert "get" in spec["paths"][self.PATH]
def test_auth_me_returns_401_without_forward_auth(self, client):
"""Should return 401 when accessed without forward auth headers."""
response = client.get(self.PATH)
# 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"][self.PATH]["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)
# An empty groups header means no groups, not one group named "".
# oidc.py has guarded this since the initial commit — this assertion
# expected [""] and had never passed. [""] would also be unsafe: any
# authorization check doing `"" in groups` would match.
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