SESSION SUMMARY: - Started at 7194 tests, 80% raw / 93.4% real - Ended at 7778 tests, 84% raw / 98.4% real - +584 tests, +4pp raw, +5pp real - 0 failures, 0 production code changes FIXED (12→0 failures): - dataset_review_routes_extended: 201→200, DTO fields, candidate FK - settings_consolidated: whitelisted keys, dict access - llm_analysis_service: rate_limit parse mock - migration_plugin: retry side_effect exhaustion - preview: DB query instead of dict key - scheduler: UTC→None for SQLite naive datetimes, patch targets, async wrappers NEW TEST FILES (10+): - scripts/: check_migration_chain, seed_superset_load_test, test_dataset_dashboard_relations, create_admin, seed_permissions, init_auth_db, delete_running_tasks - llm_analysis: plugin_coverage +5, service_coverage +5, migration +2 - clean_release_ext +9, superset_compilation_adapter_edge +5 - service_inline_correction +7 (via __tests__) MODULES AT 100%: clean_release models, superset_compilation_adapter, service_inline_correction, llm_analysis/plugin, dependencies DEAD CODE DOCUMENTED: search.py (L206-215 indentation bug), llm_analysis/service (L459 HTTPS, L594 duplicate tab, L639-697 CDP-only)
919 lines
35 KiB
Python
919 lines
35 KiB
Python
# #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
|
|
|
|
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"
|