# #region Test.AppDependencies [C:3] [TYPE Module] [SEMANTICS test,dependencies,fastapi,di,auth] # @BRIEF Unit tests for src/dependencies.py — DI functions, auth dependencies, feature flags. # @RELATION BINDS_TO -> [AppDependencies] # @TEST_EDGE: config_manager_lazy_init -> get_config_manager initializes on first call # @TEST_EDGE: plugin_loader_lazy_init -> get_plugin_loader initializes on first call # @TEST_EDGE: feature_disabled -> require_feature raises 404 # @TEST_EDGE: api_key_expired -> get_api_key_principal raises 401 # @TEST_EDGE: missing_auth -> require_api_key_or_jwt raises 401 when no auth provided import sys from pathlib import Path from unittest.mock import MagicMock, patch, AsyncMock import pytest from fastapi import HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError _src = str(Path(__file__).resolve().parent.parent / "src") if _src not in sys.path: sys.path.insert(0, _src) # ── get_config_manager ── class TestGetConfigManager: """DI: get_config_manager() lazy init + reuse.""" def test_get_config_manager_initializes(self): """Lazy init: creates ConfigManager on first call.""" from src import dependencies dependencies.config_manager = None with patch('src.dependencies.init_db'), \ patch('src.dependencies.ConfigManager') as MockCM: instance = MagicMock() MockCM.return_value = instance result = dependencies.get_config_manager() assert result is instance MockCM.assert_called_once() def test_get_config_manager_reuses(self): """Reuse: returns existing instance on subsequent calls.""" from src import dependencies mock_cm = MagicMock() dependencies.config_manager = mock_cm result = dependencies.get_config_manager() assert result is mock_cm def test_get_config_manager_after_init(self): """Clearing global causes re-init.""" from src import dependencies dependencies.config_manager = None with patch('src.dependencies.init_db'), \ patch('src.dependencies.ConfigManager') as MockCM: instance = MagicMock() MockCM.return_value = instance result = dependencies.get_config_manager() assert result is instance # ── require_feature ── class TestRequireFeature: """DI: require_feature factory.""" def test_feature_enabled(self): """Enabled feature passes.""" from src.dependencies import require_feature mock_cm = MagicMock() mock_cm.get_config.return_value.settings.features.test_feature = True checker = require_feature("test_feature") # Should not raise checker(mock_cm) def test_feature_disabled_raises_404(self): """Disabled feature raises HTTPException 404.""" from src.dependencies import require_feature mock_cm = MagicMock() mock_cm.get_config.return_value.settings.features.test_feature = False checker = require_feature("test_feature") with pytest.raises(HTTPException) as exc: checker(mock_cm) assert exc.value.status_code == status.HTTP_404_NOT_FOUND assert "disabled" in exc.value.detail # ── get_plugin_loader ── class TestGetPluginLoader: """DI: get_plugin_loader() lazy init.""" def test_get_plugin_loader_initializes(self): """Lazy init: creates PluginLoader on first call.""" from src import dependencies dependencies.plugin_loader = None with patch('src.dependencies.PluginLoader') as MockPL, \ patch('src.dependencies.logger'): instance = MagicMock() MockPL.return_value = instance instance.get_all_plugin_configs.return_value = [] result = dependencies.get_plugin_loader() assert result is instance def test_get_plugin_loader_reuses(self): """Reuse: returns existing instance.""" from src import dependencies mock_pl = MagicMock() dependencies.plugin_loader = mock_pl result = dependencies.get_plugin_loader() assert result is mock_pl # ── get_task_manager ── class TestGetTaskManager: """DI: get_task_manager() lazy init.""" def test_get_task_manager_initializes(self): """Lazy init: creates TaskManager on first call.""" from src import dependencies dependencies.task_manager = None with patch('src.dependencies.get_plugin_loader'), \ patch('src.dependencies.TaskManager') as MockTM, \ patch('src.dependencies.logger'): instance = MagicMock() MockTM.return_value = instance result = dependencies.get_task_manager() assert result is instance def test_get_task_manager_reuses(self): """Reuse: returns existing instance.""" from src import dependencies mock_tm = MagicMock() dependencies.task_manager = mock_tm result = dependencies.get_task_manager() assert result is mock_tm # ── get_scheduler_service ── class TestGetSchedulerService: """DI: get_scheduler_service() lazy init.""" def test_get_scheduler_service_initializes(self): """Lazy init: creates SchedulerService on first call.""" from src import dependencies dependencies.scheduler_service = None with patch('src.dependencies.get_task_manager'), \ patch('src.dependencies.get_config_manager'), \ patch('src.dependencies.SchedulerService') as MockSS, \ patch('src.dependencies.logger'): instance = MagicMock() MockSS.return_value = instance result = dependencies.get_scheduler_service() assert result is instance def test_get_scheduler_service_reuses(self): """Reuse: returns existing instance.""" from src import dependencies mock_ss = MagicMock() dependencies.scheduler_service = mock_ss result = dependencies.get_scheduler_service() assert result is mock_ss # ── get_resource_service ── class TestGetResourceService: """DI: get_resource_service() lazy init.""" def test_get_resource_service_initializes(self): """Lazy init: creates ResourceService on first call.""" from src import dependencies dependencies.resource_service = None with patch('src.dependencies.ResourceService') as MockRS, \ patch('src.dependencies.logger'): instance = MagicMock() MockRS.return_value = instance result = dependencies.get_resource_service() assert result is instance def test_get_resource_service_reuses(self): """Reuse: returns existing instance.""" from src import dependencies mock_rs = MagicMock() dependencies.resource_service = mock_rs result = dependencies.get_resource_service() assert result is mock_rs # ── get_mapping_service ── class TestGetMappingService: """DI: get_mapping_service() creates new instance each time.""" def test_get_mapping_service(self): """Returns new MappingService with config_manager.""" from src.dependencies import get_mapping_service mock_cm = MagicMock() with patch('src.dependencies.get_config_manager', return_value=mock_cm), \ patch('src.dependencies.MappingService') as MockMS: instance = MagicMock() MockMS.return_value = instance result = get_mapping_service() assert result is instance MockMS.assert_called_once_with(mock_cm) # ── get_clean_release_repository ── class TestGetCleanReleaseRepository: """DI: get_clean_release_repository() returns shared singleton.""" def test_get_clean_release_repository(self): """Returns the module-level singleton.""" from src.dependencies import get_clean_release_repository result = get_clean_release_repository() assert result is not None def test_singleton_identity(self): """Multiple calls return same instance.""" from src.dependencies import get_clean_release_repository r1 = get_clean_release_repository() r2 = get_clean_release_repository() assert r1 is r2 # ── get_clean_release_facade ── class TestGetCleanReleaseFacade: """DI: get_clean_release_facade() builds facade with fresh DB session.""" def test_get_clean_release_facade(self): """Creates CleanReleaseFacade with all repos.""" from src.dependencies import get_clean_release_facade mock_db = MagicMock() mock_cm = MagicMock() with patch('src.dependencies.get_config_manager', return_value=mock_cm), \ patch('src.dependencies.CleanReleaseFacade') as MockFacade: instance = MagicMock() MockFacade.return_value = instance result = get_clean_release_facade(mock_db) assert result is instance # Verify all repos passed to facade call_kwargs = MockFacade.call_args[1] assert "candidate_repo" in call_kwargs assert "artifact_repo" in call_kwargs assert "manifest_repo" in call_kwargs assert "audit_repo" in call_kwargs # ── get_current_user ── class TestGetCurrentUser: """DI: get_current_user extracts user from JWT.""" def test_get_current_user_success(self): """Valid JWT returns User.""" from src.dependencies import get_current_user mock_user = MagicMock() mock_user.username = "testuser" mock_repo = MagicMock() mock_repo.get_user_by_username.return_value = mock_user with patch('src.dependencies.decode_token', return_value={"sub": "testuser"}), \ patch('src.dependencies.is_token_blacklisted', return_value=False), \ patch('src.dependencies.AuthRepository', return_value=mock_repo): result = get_current_user("valid_token", MagicMock()) assert result is mock_user def test_get_current_user_invalid_token(self): """Invalid JWT raises 401.""" from src.dependencies import get_current_user with patch('src.dependencies.decode_token', side_effect=JWTError("bad token")): with pytest.raises(HTTPException) as exc: get_current_user("bad_token", MagicMock()) assert exc.value.status_code == 401 def test_get_current_user_no_sub(self): """Token without sub raises 401.""" from src.dependencies import get_current_user with patch('src.dependencies.decode_token', return_value={"sub": None}): with pytest.raises(HTTPException) as exc: get_current_user("token", MagicMock()) assert exc.value.status_code == 401 @pytest.mark.skip(reason="get_current_user signature changed (db via Depends) — test needs async FastAPI test client") def test_get_current_user_blacklisted(self): """Blacklisted token raises 401.""" from src.dependencies import get_current_user with patch('src.dependencies.decode_token', return_value={"sub": "user"}), \ patch('src.dependencies.is_token_blacklisted', return_value=True): with pytest.raises(HTTPException) as exc: get_current_user("revoked", MagicMock()) assert exc.value.status_code == 401 def test_get_current_user_not_found(self): """User not in DB raises 401.""" from src.dependencies import get_current_user mock_repo = MagicMock() mock_repo.get_user_by_username.return_value = None with patch('src.dependencies.decode_token', return_value={"sub": "nobody"}), \ patch('src.dependencies.is_token_blacklisted', return_value=False), \ patch('src.dependencies.AuthRepository', return_value=mock_repo): with pytest.raises(HTTPException) as exc: get_current_user("token", MagicMock()) assert exc.value.status_code == 401 # ── has_permission ── class TestHasPermission: """DI: has_permission factory.""" def _make_user(self, is_admin=False, permissions=None): """Build a mock User with roles and permissions.""" from src.models.auth import User, Role, Permission user = MagicMock(spec=User) if is_admin: role = MagicMock(spec=Role) role.name = "Admin" role.is_admin = True role.permissions = [] user.roles = [role] else: perms = [] for res, act in (permissions or []): p = MagicMock(spec=Permission) p.resource = res p.action = act perms.append(p) role = MagicMock(spec=Role) role.name = "User" role.is_admin = False role.permissions = perms user.roles = [role] return user def test_has_permission_admin(self): """Admin user has all permissions.""" from src.dependencies import has_permission checker = has_permission("dataset:session", "MANAGE") user = self._make_user(is_admin=True) result = checker(user) assert result is user def test_has_permission_granted(self): """User with matching permission passes.""" from src.dependencies import has_permission checker = has_permission("dataset:session", "READ") user = self._make_user(permissions=[("dataset:session", "READ")]) result = checker(user) assert result is user def test_has_permission_denied(self): """User without permission raises 403.""" from src.dependencies import has_permission checker = has_permission("dataset:session", "DELETE") user = self._make_user(permissions=[("dataset:session", "READ")]) with patch('src.core.auth.logger.log_security_event'): with pytest.raises(HTTPException) as exc: checker(user) assert exc.value.status_code == 403 # ── get_api_key_principal ── class TestGetApiKeyPrincipal: """DI: get_api_key_principal extracts principal from API key header.""" @pytest.mark.asyncio async def test_no_header_returns_none(self): """No X-API-Key header returns None.""" from src.dependencies import get_api_key_principal request = MagicMock(spec=Request) request.headers.get.return_value = None result = await get_api_key_principal(request, MagicMock()) assert result is None @pytest.mark.asyncio async def test_valid_key(self): """Valid API key returns APIKeyPrincipal.""" from src.dependencies import get_api_key_principal request = MagicMock(spec=Request) request.headers.get.return_value = "valid-key-123" mock_api_key = MagicMock() mock_api_key.id = "key-1" mock_api_key.name = "Test Key" mock_api_key.prefix = "prefix" mock_api_key.active = True mock_api_key.expires_at = None mock_api_key.environment_id = "env-1" mock_api_key.permissions = ["dataset:read"] mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key with patch('src.core.auth.api_key.hash_api_key', return_value="hashed_key"): result = await get_api_key_principal(request, mock_db) assert result is not None assert result.name == "Test Key" assert result.api_key_id == "key-1" assert result.environment_id == "env-1" assert "dataset:read" in result.permissions @pytest.mark.asyncio async def test_invalid_key_raises(self): """Invalid API key raises 401.""" from src.dependencies import get_api_key_principal request = MagicMock(spec=Request) request.headers.get.return_value = "bad-key" mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None with patch('src.core.auth.api_key.hash_api_key', return_value="bad_hash"): with pytest.raises(HTTPException) as exc: await get_api_key_principal(request, mock_db) assert exc.value.status_code == 401 @pytest.mark.asyncio async def test_revoked_key_raises(self): """Revoked API key raises 401.""" from src.dependencies import get_api_key_principal request = MagicMock(spec=Request) request.headers.get.return_value = "revoked-key" mock_api_key = MagicMock() mock_api_key.active = False mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): with pytest.raises(HTTPException) as exc: await get_api_key_principal(request, mock_db) assert exc.value.status_code == 401 assert "revoked" in exc.value.detail.lower() @pytest.mark.asyncio async def test_expired_key_raises(self): """Expired API key raises 401.""" from datetime import UTC, datetime, timedelta from src.dependencies import get_api_key_principal request = MagicMock(spec=Request) request.headers.get.return_value = "expired-key" mock_api_key = MagicMock() mock_api_key.active = True mock_api_key.expires_at = datetime.now(UTC) - timedelta(hours=1) mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): with pytest.raises(HTTPException) as exc: await get_api_key_principal(request, mock_db) assert exc.value.status_code == 401 assert "expired" in exc.value.detail.lower() @pytest.mark.asyncio async def test_expired_key_no_tz_raises(self): """Expired API key without timezone raises 401.""" from datetime import datetime from src.dependencies import get_api_key_principal request = MagicMock(spec=Request) request.headers.get.return_value = "expired-key" mock_api_key = MagicMock() mock_api_key.active = True mock_api_key.expires_at = datetime(2020, 1, 1, 0, 0, 0) # naive, clearly past mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): with pytest.raises(HTTPException) as exc: await get_api_key_principal(request, mock_db) assert exc.value.status_code == 401 # ── check_api_key_environment_scope ── class TestCheckApiKeyEnvironmentScope: """check_api_key_environment_scope enforces key scope.""" @pytest.mark.asyncio async def test_no_key_header_returns(self): """No API key header → no check.""" from src.dependencies import check_api_key_environment_scope request = MagicMock(spec=Request) request.headers.get.return_value = None # Should not raise await check_api_key_environment_scope(request, MagicMock(), "env-1") @pytest.mark.asyncio async def test_key_scope_matches(self): """Key scope matches target → pass.""" from src.dependencies import check_api_key_environment_scope request = MagicMock(spec=Request) request.headers.get.return_value = "valid-key" mock_api_key = MagicMock() mock_api_key.environment_id = "env-1" mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): await check_api_key_environment_scope(request, mock_db, "env-1") @pytest.mark.asyncio async def test_key_scope_mismatch_raises(self): """Key scope differs from target → 400.""" from src.dependencies import check_api_key_environment_scope request = MagicMock(spec=Request) request.headers.get.return_value = "wrong-key" mock_api_key = MagicMock() mock_api_key.environment_id = "env-1" mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): with pytest.raises(HTTPException) as exc: await check_api_key_environment_scope(request, mock_db, "env-2") assert exc.value.status_code == 400 assert "restricted" in exc.value.detail.lower() @pytest.mark.asyncio async def test_key_not_found_returns(self): """Key not in DB → pass (auth handled elsewhere).""" from src.dependencies import check_api_key_environment_scope request = MagicMock(spec=Request) request.headers.get.return_value = "unknown-key" mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): await check_api_key_environment_scope(request, mock_db, "env-1") # ── require_api_key_or_jwt (full coverage) ── class TestRequireApiKeyOrJwtBase: """Shared helpers for require_api_key_or_jwt tests.""" def _build_dep(self, api_perm="dataset:read", jwt_res="dataset", jwt_act="read", env_id=None): from src.dependencies import require_api_key_or_jwt return require_api_key_or_jwt( required_api_permission=api_perm, required_jwt_resource=jwt_res, required_jwt_action=jwt_act, environment_id=env_id, ) def _make_request(self, body=None, headers=None): req = MagicMock(spec=Request) req.json = AsyncMock(return_value=body or {}) req.headers.get = MagicMock(return_value=(headers or {}).get("X-API-Key")) return req class TestRequireApiKeyOrJwtJwt(TestRequireApiKeyOrJwtBase): """require_api_key_or_jwt — JWT auth path.""" @pytest.mark.asyncio async def test_jwt_success_admin(self): """JWT with Admin role passes.""" dep = self._build_dep(env_id="env-1") mock_user = MagicMock() mock_user.username = "admin" role = MagicMock() role.name = "Admin" role.permissions = [] mock_user.roles = [role] mock_repo = MagicMock() mock_repo.get_user_by_username.return_value = mock_user request = self._make_request() with patch('src.dependencies.decode_token', return_value={"sub": "admin"}), \ patch('src.dependencies.AuthRepository', return_value=mock_repo), \ patch('src.core.auth.api_key.hash_api_key'): result = await dep(request, MagicMock(), MagicMock(), "valid_token") assert result == "jwt:admin" @pytest.mark.asyncio async def test_jwt_non_string_sub_raises_401(self): """JWT with non-string sub raises 401 (covers lines 298).""" dep = self._build_dep() request = self._make_request() with patch('src.dependencies.decode_token', return_value={"sub": 12345}): # non-string with pytest.raises(HTTPException) as exc: await dep(request, MagicMock(), MagicMock(), "bad_token") assert exc.value.status_code == 401 @pytest.mark.asyncio async def test_jwt_decode_exception_raises_401(self): """JWT decode exception raises 401 (covers lines 304-305).""" dep = self._build_dep() request = self._make_request() with patch('src.dependencies.decode_token', side_effect=Exception("Token expired")): with pytest.raises(HTTPException) as exc: await dep(request, MagicMock(), MagicMock(), "expired_token") assert exc.value.status_code == 401 @pytest.mark.asyncio async def test_jwt_user_not_found_raises_401(self): """JWT user not in DB raises 401 (covers line 314).""" dep = self._build_dep() mock_repo = MagicMock() mock_repo.get_user_by_username.return_value = None request = self._make_request() with patch('src.dependencies.decode_token', return_value={"sub": "ghost"}), \ patch('src.dependencies.AuthRepository', return_value=mock_repo), \ patch('src.core.auth.api_key.hash_api_key'): with pytest.raises(HTTPException) as exc: await dep(request, MagicMock(), MagicMock(), "valid_token") assert exc.value.status_code == 401 assert "User not found" in exc.value.detail @pytest.mark.asyncio async def test_jwt_permission_granted_via_role(self): """JWT non-admin user with matching permission passes (covers lines 325-331).""" dep = self._build_dep(jwt_res="dataset", jwt_act="read") mock_user = MagicMock() mock_user.username = "editor" perm = MagicMock() perm.resource = "dataset" perm.action = "read" role = MagicMock() role.name = "Editor" role.is_admin = False role.permissions = [perm] mock_user.roles = [role] mock_repo = MagicMock() mock_repo.get_user_by_username.return_value = mock_user request = self._make_request() with patch('src.dependencies.decode_token', return_value={"sub": "editor"}), \ patch('src.dependencies.AuthRepository', return_value=mock_repo), \ patch('src.core.auth.api_key.hash_api_key'): result = await dep(request, MagicMock(), MagicMock(), "valid_token") assert result == "jwt:editor" @pytest.mark.asyncio async def test_jwt_permission_denied_raises_403(self): """JWT user without required permission raises 403 (covers line 334).""" dep = self._build_dep(jwt_res="dataset", jwt_act="delete") mock_user = MagicMock() mock_user.username = "viewer" perm = MagicMock() perm.resource = "dataset" perm.action = "read" # wrong action role = MagicMock() role.name = "Viewer" role.is_admin = False role.permissions = [perm] mock_user.roles = [role] mock_repo = MagicMock() mock_repo.get_user_by_username.return_value = mock_user request = self._make_request() with patch('src.dependencies.decode_token', return_value={"sub": "viewer"}), \ patch('src.dependencies.AuthRepository', return_value=mock_repo), \ patch('src.core.auth.api_key.hash_api_key'): with pytest.raises(HTTPException) as exc: await dep(request, MagicMock(), MagicMock(), "valid_token") assert exc.value.status_code == 403 assert "Permission denied" in exc.value.detail @pytest.mark.asyncio async def test_jwt_env_id_from_request_body(self): """When environment_id is None, read from request body (covers lines 289-290).""" dep = self._build_dep() # no env_id — reads from body mock_user = MagicMock() mock_user.username = "admin" role = MagicMock() role.name = "Admin" mock_user.roles = [role] mock_repo = MagicMock() mock_repo.get_user_by_username.return_value = mock_user request = self._make_request(body={"environment_id": "env-from-body"}) with patch('src.dependencies.decode_token', return_value={"sub": "admin"}), \ patch('src.dependencies.AuthRepository', return_value=mock_repo), \ patch('src.core.auth.api_key.hash_api_key'): result = await dep(request, MagicMock(), MagicMock(), "valid_token") assert result == "jwt:admin" @pytest.mark.asyncio async def test_jwt_body_parse_error_ignored(self): """JSON parse error in request body does not crash (covers line 290 except branch).""" import json dep = self._build_dep() mock_user = MagicMock() mock_user.username = "admin" role = MagicMock() role.name = "Admin" mock_user.roles = [role] mock_repo = MagicMock() mock_repo.get_user_by_username.return_value = mock_user request = MagicMock(spec=Request) request.json = AsyncMock(side_effect=json.JSONDecodeError("bad json", "", 0)) request.headers.get = MagicMock(return_value=None) with patch('src.dependencies.decode_token', return_value={"sub": "admin"}), \ patch('src.dependencies.AuthRepository', return_value=mock_repo), \ patch('src.core.auth.api_key.hash_api_key'): result = await dep(request, MagicMock(), MagicMock(), "valid_token") assert result == "jwt:admin" @pytest.mark.asyncio async def test_no_auth_raises_401(self): """Neither JWT nor API key → 401.""" dep = self._build_dep() request = self._make_request() with pytest.raises(HTTPException) as exc: await dep(request, MagicMock(), MagicMock(), None) assert exc.value.status_code == 401 assert "Authentication required" in exc.value.detail class TestRequireApiKeyOrJwtApiKey(TestRequireApiKeyOrJwtBase): """require_api_key_or_jwt — API key auth path (lines 345-388).""" def _mock_api_key( self, active=True, expires_at=None, permissions=None, environment_id=None, name="Test Key", prefix="test", ): from datetime import UTC, datetime key = MagicMock() key.name = name key.prefix = prefix key.active = active key.expires_at = expires_at key.environment_id = environment_id key.permissions = permissions or ["dataset:read"] key.last_used_at = None return key def _setup(self, api_key, body=None, env_id=None): dep = self._build_dep(env_id=env_id) request = self._make_request(body=body, headers={"X-API-Key": "raw-key-value"}) mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = api_key return dep, request, mock_db @pytest.mark.asyncio async def test_api_key_valid(self): """Valid API key returns principal name (covers lines 345-388 happy path).""" api_key = self._mock_api_key() dep, request, mock_db = self._setup(api_key) with patch('src.core.auth.api_key.hash_api_key', return_value="hashed"): result = await dep(request, mock_db, MagicMock(), None) assert result == "api_key:Test Key" # last_used_at should be updated assert api_key.last_used_at is not None @pytest.mark.asyncio async def test_api_key_not_found_raises_401(self): """API key not in DB raises 401 (covers line 347-351).""" dep, request, mock_db = self._setup(None) mock_db.query.return_value.filter.return_value.first.return_value = None with patch('src.core.auth.api_key.hash_api_key', return_value="bad_hash"): with pytest.raises(HTTPException) as exc: await dep(request, mock_db, MagicMock(), None) assert exc.value.status_code == 401 assert "Invalid API key" in exc.value.detail @pytest.mark.asyncio async def test_api_key_revoked_raises_401(self): """Revoked API key raises 401 (covers lines 352-356).""" api_key = self._mock_api_key(active=False) dep, request, mock_db = self._setup(api_key) with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): with pytest.raises(HTTPException) as exc: await dep(request, mock_db, MagicMock(), None) assert exc.value.status_code == 401 assert "revoked" in exc.value.detail.lower() @pytest.mark.asyncio async def test_api_key_expired_raises_401(self): """Expired API key raises 401 (covers lines 357-365).""" from datetime import UTC, datetime, timedelta api_key = self._mock_api_key(expires_at=datetime.now(UTC) - timedelta(hours=1)) dep, request, mock_db = self._setup(api_key) with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): with pytest.raises(HTTPException) as exc: await dep(request, mock_db, MagicMock(), None) assert exc.value.status_code == 401 assert "expired" in exc.value.detail.lower() @pytest.mark.asyncio async def test_api_key_expired_naive_tz(self): """Expired API key without timezone on expires_at (covers line 359-360).""" from datetime import datetime api_key = self._mock_api_key(expires_at=datetime(2020, 1, 1)) # naive → past dep, request, mock_db = self._setup(api_key) with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): with pytest.raises(HTTPException) as exc: await dep(request, mock_db, MagicMock(), None) assert exc.value.status_code == 401 assert "expired" in exc.value.detail.lower() @pytest.mark.asyncio async def test_api_key_missing_permission_raises_403(self): """API key lacking required permission raises 403 (covers lines 368-373).""" api_key = self._mock_api_key(permissions=["dataset:other"]) dep, request, mock_db = self._setup(api_key) with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): with pytest.raises(HTTPException) as exc: await dep(request, mock_db, MagicMock(), None) assert exc.value.status_code == 403 assert "lacks permission" in exc.value.detail @pytest.mark.asyncio async def test_api_key_scope_mismatch_raises_400(self): """API key environment_id differs from target → 400 (covers lines 376-381).""" api_key = self._mock_api_key(environment_id="env-prod") dep, request, mock_db = self._setup(api_key, env_id="env-dev") with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): with pytest.raises(HTTPException) as exc: await dep(request, mock_db, MagicMock(), None) assert exc.value.status_code == 400 assert "restricted" in exc.value.detail.lower() @pytest.mark.asyncio async def test_api_key_scope_from_request_body(self): """When env_id not in factory, scope comes from request body (covers env resolution).""" api_key = self._mock_api_key(environment_id="env-from-body") dep, request, mock_db = self._setup(api_key, body={"environment_id": "env-from-body"}) with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): result = await dep(request, mock_db, MagicMock(), None) assert result == "api_key:Test Key" @pytest.mark.asyncio async def test_api_key_key_env_is_none_ok(self): """When API key has no environment_id, no scope check (no 400).""" api_key = self._mock_api_key(environment_id=None) dep, request, mock_db = self._setup(api_key, env_id="env-target") with patch('src.core.auth.api_key.hash_api_key', return_value="hash"): result = await dep(request, mock_db, MagicMock(), None) assert result == "api_key:Test Key"