feat(auth): implement API key authentication and management

Introduce a new API key authentication mechanism to support service-to-service
communication (e.g., Airflow, CI/CD) without browser-based JWT login.

- Add `APIKey` model and `APIKeyPrincipal` dependency for RBAC.
- Implement `require_api_key_or_jwt` dependency with environment scoping.
- Add admin CRUD endpoints for API key lifecycle management.
- Add API key management UI in System Settings.
- Update maintenance API to support explicit `environment_id` and API key auth.
- Add i18n support for API keys and connection settings.
- Include Python and Bash usage examples in the `examples/` directory.
- Update technical specifications and documentation.
This commit is contained in:
2026-05-25 11:35:27 +03:00
parent 31680a1bc9
commit 320f82ab95
39 changed files with 3359 additions and 707 deletions

View File

@@ -0,0 +1,500 @@
# #region TestAPIKeyAuth [C:3] [TYPE Module] [SEMANTICS test, api_key, auth, dependency]
# @BRIEF Contract tests for API key authentication — valid key, invalid, revoked, expired, missing permission,
# environment scoping, JWT precedence. Uses TestClient with dependency overrides.
# @RELATION BINDS_TO -> [APIKeyUtilities]
# @RELATION BINDS_TO -> [APIKeyModel]
# @RELATION BINDS_TO -> [MaintenanceRoutesModule]
# @TEST_CONTRACT: get_api_key_principal returns APIKeyPrincipal for valid key, None for no header, 401 for invalid/revoked/expired.
# @TEST_CONTRACT: require_api_key_or_jwt rejects invalid/revoked/expired keys, checks permissions, enforces environment scope.
# @TEST_EDGE: missing_header -> returns None
# @TEST_EDGE: invalid_key -> 401
# @TEST_EDGE: revoked_key -> 401
# @TEST_EDGE: expired_key -> 401
# @TEST_EDGE: missing_permission -> 403
# @TEST_EDGE: environment_scope_mismatch -> 400
# @TEST_EDGE: jwt_precedence -> JWT takes precedence over API key
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException, status
from fastapi.testclient import TestClient
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture
def db_session():
"""Create an in-memory SQLite session with the api_keys table."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from src.models.mapping import Base
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
@pytest.fixture
def valid_api_key(db_session):
"""Create a valid API key and return its raw value, prefix, and database row."""
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Test Key",
permissions=["maintenance:start", "maintenance:end"],
active=True,
)
db_session.add(api_key)
db_session.commit()
return raw, api_key
@pytest.fixture
def client():
"""Create a TestClient."""
from src.app import app
return TestClient(app)
@pytest.fixture
def mock_db():
"""Patch get_db dependency with in-memory SQLite session (same pattern as test_maintenance_api)."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from src.models.mapping import Base
from src.dependencies import get_db
from src.app import app
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
# Add default maintenance settings (needed by /api/maintenance endpoints)
from src.models.maintenance import MaintenanceSettings, DashboardScope
settings = MaintenanceSettings(
id="default",
target_environment_id="test-env",
display_timezone="UTC",
banner_template="Test: {message} ({start_time}-{end_time})",
dashboard_scope=DashboardScope.PUBLISHED_ONLY,
excluded_dashboard_ids=[],
forced_dashboard_ids=[],
)
session.add(settings)
session.commit()
def _get_db_override():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _get_db_override
yield session
app.dependency_overrides.pop(get_db, None)
session.close()
@pytest.fixture
def mock_task_manager():
"""Patch get_task_manager to return a mock."""
from src.dependencies import get_task_manager
from src.app import app
mock_tm = MagicMock()
mock_task = MagicMock()
mock_task.id = "test-task-id-123"
mock_tm.create_task = AsyncMock(return_value=mock_task)
app.dependency_overrides[get_task_manager] = lambda: mock_tm
yield mock_tm
app.dependency_overrides.pop(get_task_manager, None)
# ── Tests for get_api_key_principal ───────────────────────────
class TestGetApiKeyPrincipal:
"""Contract tests for get_api_key_principal dependency."""
# #region test_no_header_returns_none [C:2] [TYPE Function]
# @BRIEF No X-API-Key header → returns None.
@pytest.mark.asyncio
async def test_no_header_returns_none(self):
from src.dependencies import get_api_key_principal
from fastapi import Request
# Mock a request without the header
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = None
mock_db = MagicMock()
result = await get_api_key_principal(mock_request, mock_db)
assert result is None
# #endregion test_no_header_returns_none
# #region test_valid_key_returns_principal [C:2] [TYPE Function]
# @BRIEF Valid API key → returns APIKeyPrincipal with correct fields.
@pytest.mark.asyncio
async def test_valid_key_returns_principal(self, db_session, valid_api_key):
from src.dependencies import get_api_key_principal
from fastapi import Request
raw_key, api_key_row = valid_api_key
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = raw_key
result = await get_api_key_principal(mock_request, db_session)
assert result is not None
assert result.name == "Test Key"
assert result.api_key_id == api_key_row.id
assert result.environment_id is None
assert "maintenance:start" in result.permissions
# #endregion test_valid_key_returns_principal
# #region test_invalid_key_raises_401 [C:2] [TYPE Function]
# @BRIEF Invalid API key → 401.
@pytest.mark.asyncio
async def test_invalid_key_raises_401(self, db_session):
from src.dependencies import get_api_key_principal
from fastapi import Request
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = "ssk_invalid_key_that_does_not_exist"
with pytest.raises(HTTPException) as exc:
await get_api_key_principal(mock_request, db_session)
assert exc.value.status_code == 401
# #endregion test_invalid_key_raises_401
# #region test_revoked_key_raises_401 [C:2] [TYPE Function]
# @BRIEF Revoked (active=False) API key → 401.
@pytest.mark.asyncio
async def test_revoked_key_raises_401(self, db_session):
from src.core.auth.api_key import generate_api_key
from src.dependencies import get_api_key_principal
from src.models.api_key import APIKey
from fastapi import Request
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Revoked Key",
permissions=["maintenance:start"],
active=False, # revoked
)
db_session.add(api_key)
db_session.commit()
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = raw
with pytest.raises(HTTPException) as exc:
await get_api_key_principal(mock_request, db_session)
assert exc.value.status_code == 401
# #endregion test_revoked_key_raises_401
# #region test_expired_key_raises_401 [C:2] [TYPE Function]
# @BRIEF Expired API key → 401.
@pytest.mark.asyncio
async def test_expired_key_raises_401(self, db_session):
from src.core.auth.api_key import generate_api_key
from src.dependencies import get_api_key_principal
from src.models.api_key import APIKey
from fastapi import Request
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Expired Key",
permissions=["maintenance:start"],
active=True,
expires_at=datetime.now(timezone.utc) - timedelta(hours=1), # expired
)
db_session.add(api_key)
db_session.commit()
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = raw
with pytest.raises(HTTPException) as exc:
await get_api_key_principal(mock_request, db_session)
assert exc.value.status_code == 401
# #endregion test_expired_key_raises_401
# #endregion TestAPIKeyAuth
# ── Tests for require_api_key_or_jwt (integration via TestClient) ──────
class TestRequireApiKeyOrJwt:
"""Contract tests for require_api_key_or_jwt dependency factory via TestClient."""
API_START_URL = "/api/maintenance/start"
START_PAYLOAD = {
"tables": ["raw.sales"],
"start_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"end_time": (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat(),
"message": "Test maintenance",
"environment_id": "ss-dev",
}
@staticmethod
def _create_api_key(db, environment_id=None, permissions=None):
"""Helper to create an API key in the test DB and return the raw key."""
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Test Key",
permissions=permissions or ["maintenance:start"],
active=True,
environment_id=environment_id,
)
db.add(api_key)
db.commit()
return raw
# #region test_api_key_auth_valid [C:2] [TYPE Function]
# @BRIEF Valid API key with matching permission → 202.
def test_api_key_auth_valid(self, client, mock_db, mock_task_manager):
raw_key = self._create_api_key(mock_db, permissions=["maintenance:start"])
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": raw_key},
)
assert response.status_code == 202
data = response.json()
assert "task_id" in data
assert data["status"] == "pending"
# #endregion test_api_key_auth_valid
# #region test_api_key_auth_wrong_permission [C:2] [TYPE Function]
# @BRIEF API key lacks required permission → 403.
def test_api_key_auth_wrong_permission(self, client, mock_db, mock_task_manager):
# Key has 'maintenance:end' only, not 'maintenance:start'
raw_key = self._create_api_key(mock_db, permissions=["maintenance:end"])
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": raw_key},
)
assert response.status_code == 403
data = response.json()
assert "detail" in data
# #endregion test_api_key_auth_wrong_permission
# #region test_api_key_auth_revoked [C:2] [TYPE Function]
# @BRIEF Revoked API key → 401.
def test_api_key_auth_revoked(self, client, mock_db, mock_task_manager):
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Revoked Key",
permissions=["maintenance:start"],
active=False,
)
mock_db.add(api_key)
mock_db.commit()
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": raw},
)
assert response.status_code == 401
assert "revoked" in response.json().get("detail", "").lower()
# #endregion test_api_key_auth_revoked
# #region test_api_key_auth_expired [C:2] [TYPE Function]
# @BRIEF Expired API key → 401.
def test_api_key_auth_expired(self, client, mock_db, mock_task_manager):
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Expired Key",
permissions=["maintenance:start"],
active=True,
expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
mock_db.add(api_key)
mock_db.commit()
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": raw},
)
assert response.status_code == 401
assert "expired" in response.json().get("detail", "").lower()
# #endregion test_api_key_auth_expired
# #region test_api_key_auth_invalid [C:2] [TYPE Function]
# @BRIEF Non-existent key → 401.
def test_api_key_auth_invalid(self, client, mock_db, mock_task_manager):
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": "ssk_invalid_nonexistent_key"},
)
assert response.status_code == 401
assert "invalid" in response.json().get("detail", "").lower()
# #endregion test_api_key_auth_invalid
# #region test_api_key_auth_environment_scope [C:2] [TYPE Function]
# @BRIEF Key scoped to ss-dev, request for ss-prod → 400.
def test_api_key_auth_environment_scope(self, client, mock_db, mock_task_manager):
raw_key = self._create_api_key(
mock_db,
environment_id="ss-dev",
permissions=["maintenance:start"],
)
payload = dict(self.START_PAYLOAD)
payload["environment_id"] = "ss-prod" # Mismatch!
response = client.post(
self.API_START_URL,
json=payload,
headers={"X-API-Key": raw_key},
)
assert response.status_code == 400
data = response.json()
assert "restricted" in data.get("detail", "").lower()
assert "ss-dev" in data.get("detail", "")
# #endregion test_api_key_auth_environment_scope
# #region test_api_key_auth_environment_scope_ok [C:2] [TYPE Function]
# @BRIEF Key scoped to ss-dev, request for ss-dev → 202.
def test_api_key_auth_environment_scope_ok(self, client, mock_db, mock_task_manager):
raw_key = self._create_api_key(
mock_db,
environment_id="ss-dev",
permissions=["maintenance:start"],
)
payload = dict(self.START_PAYLOAD)
payload["environment_id"] = "ss-dev" # Match!
response = client.post(
self.API_START_URL,
json=payload,
headers={"X-API-Key": raw_key},
)
assert response.status_code == 202
data = response.json()
assert "task_id" in data
# #endregion test_api_key_auth_environment_scope_ok
# #region test_jwt_precedence [C:2] [TYPE Function]
# @BRIEF Both valid API key + valid JWT → JWT takes precedence.
def test_jwt_precedence(self, client, mock_db, mock_task_manager):
from src.core.auth.jwt import create_access_token
from src.models.auth import User, Role
from src.dependencies import get_auth_db
from src.app import app
raw_key = self._create_api_key(
mock_db,
environment_id="ss-dev",
permissions=["maintenance:start"],
)
# Create a JWT user with Admin role (full access)
role = Role(name="Admin", description="Admin role")
mock_db.add(role)
mock_db.commit()
user = User(
username="jwt-user",
password_hash="nohash",
auth_source="LOCAL",
is_active=True,
)
user.roles.append(role)
mock_db.add(user)
mock_db.commit()
# Create a valid JWT token
token = create_access_token(data={"sub": "jwt-user"})
# Override get_auth_db to use our mock_db for auth queries too
# The auth DB and main DB are separate in production, but for tests
# we use the same in-memory DB
def _get_auth_db_override():
yield mock_db
app.dependency_overrides[get_auth_db] = _get_auth_db_override
# Send request with BOTH X-API-Key and Authorization: Bearer
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={
"X-API-Key": raw_key,
"Authorization": f"Bearer {token}",
},
)
assert response.status_code == 202
data = response.json()
assert "task_id" in data
# Cleanup
app.dependency_overrides.pop(get_auth_db, None)
# #endregion test_jwt_precedence
# #endregion TestRequireApiKeyOrJwt

View File

@@ -0,0 +1,122 @@
# #region TestAPIKeyModel [C:2] [TYPE Module] [SEMANTICS test, api_key, model]
# @BRIEF Contract tests for the APIKey SQLAlchemy model — creation, hash storage, and field constraints.
# @RELATION BINDS_TO -> [APIKeyModel]
# @TEST_CONTRACT: APIKey model stores SHA-256 hash and prefix; raw key never persisted.
# @TEST_EDGE: key_hash is unique and indexed
# @TEST_EDGE: prefix is exactly 11 chars ("ssk_" + 7)
# @TEST_EDGE: active defaults to True
import pytest
from datetime import datetime, timezone
# #region clean_db [C:1] [TYPE Fixture]
# @BRIEF In-memory SQLite fixture for APIKey model tests.
@pytest.fixture
def clean_db():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from src.models.mapping import Base
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
# #endregion clean_db
class TestAPIKeyModel:
"""Verify APIKey model fields and constraints."""
# #region test_create_api_key [C:2] [TYPE Function]
# @BRIEF Create APIKey with required fields and verify defaults.
def test_create_api_key(self, clean_db):
from src.models.api_key import APIKey
api_key = APIKey(
key_hash="a" * 64,
prefix="ssk_abc1234",
name="Test Key",
permissions=["maintenance:start"],
)
clean_db.add(api_key)
clean_db.commit()
clean_db.refresh(api_key)
assert api_key.id is not None
assert api_key.key_hash == "a" * 64
assert api_key.prefix == "ssk_abc1234"
assert api_key.name == "Test Key"
assert api_key.permissions == ["maintenance:start"]
assert api_key.active is True
assert api_key.created_at is not None
assert api_key.environment_id is None
assert api_key.expires_at is None
assert api_key.last_used_at is None
# #endregion test_create_api_key
# #region test_key_hash_unique [C:2] [TYPE Function]
# @BRIEF key_hash is unique — duplicate raises IntegrityError.
def test_key_hash_unique(self, clean_db):
from sqlalchemy.exc import IntegrityError
from src.models.api_key import APIKey
clean_db.add(APIKey(
key_hash="b" * 64,
prefix="ssk_xyz7890",
name="Key 1",
permissions=["maintenance:start"],
))
clean_db.commit()
with pytest.raises(IntegrityError):
clean_db.add(APIKey(
key_hash="b" * 64, # Same hash
prefix="ssk_def5678",
name="Key 2",
permissions=["maintenance:end"],
))
clean_db.commit()
# #endregion test_key_hash_unique
# #region test_prefix_length [C:2] [TYPE Function]
# @BRIEF prefix is exactly 11 characters.
def test_prefix_length(self, clean_db):
from src.models.api_key import APIKey
api_key = APIKey(
key_hash="c" * 64,
prefix="ssk_short1", # 10 chars — should be ok in SQL
name="Short Prefix",
permissions=["maintenance:start"],
)
clean_db.add(api_key)
clean_db.commit()
# The DB allows any prefix length; the app layer enforces 11 chars
# via generate_api_key. Model just stores what it's given.
assert len(api_key.prefix) <= 11
# #endregion test_prefix_length
# #region test_active_default_true [C:2] [TYPE Function]
# @BRIEF active column defaults to True.
def test_active_default_true(self, clean_db):
from src.models.api_key import APIKey
api_key = APIKey(
key_hash="d" * 64,
prefix="ssk_def1234",
name="Default Active",
permissions=[],
)
clean_db.add(api_key)
clean_db.commit()
assert api_key.active is True
# #endregion test_active_default_true
# #endregion TestAPIKeyModel

View File

@@ -0,0 +1,258 @@
# #region TestAPIKeyRoutes [C:3] [TYPE Module] [SEMANTICS test, api_key, routes, crud]
# @BRIEF Contract tests for admin API key CRUD endpoints — list, create (raw key present), revoke, idempotent revoke.
# @RELATION BINDS_TO -> [AdminApiKeyRoutes]
# @TEST_CONTRACT: GET /api/admin/api-keys -> list of keys without raw_key or key_hash
# @TEST_CONTRACT: POST /api/admin/api-keys -> 201 with raw_key in response
# @TEST_CONTRACT: DELETE /api/admin/api-keys/{id} -> 200 with status=revoked
# @TEST_EDGE: revoked_key_delete -> 404
# @TEST_EDGE: missing_name -> 422
# @TEST_EDGE: missing_permissions -> 422
import json
import sys
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock, patch
import pytest
from fastapi import status
from fastapi.testclient import TestClient
# Patch GitService at module level to prevent /app/storage/repositories error
_mock_git_svc_cls = MagicMock()
_mock_git_svc_cls.return_value = MagicMock()
sys.modules['src.services.git._base'] = MagicMock(GitService=_mock_git_svc_cls)
sys.modules['src.services.git_service'] = MagicMock(GitService=_mock_git_svc_cls)
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture
def client():
"""Create a TestClient with all dependencies mocked."""
from src.app import app
return TestClient(app)
def _mock_admin_auth():
"""Apply dependency overrides for admin authentication."""
from src.dependencies import get_current_user
from src.app import app
mock_role = MagicMock()
mock_role.name = "Admin"
mock_role.permissions = []
mock_user = MagicMock()
mock_user.username = "test-admin"
mock_user.roles = [mock_role]
async def _mock_get_current_user():
return mock_user
app.dependency_overrides[get_current_user] = _mock_get_current_user
def _clear_overrides():
from src.app import app
app.dependency_overrides = {}
@pytest.fixture(autouse=True)
def auth_mock():
_mock_admin_auth()
yield
_clear_overrides()
@pytest.fixture
def mock_db():
"""Patch get_db dependency with in-memory SQLite session."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from src.models.mapping import Base
from src.dependencies import get_db
from src.app import app
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
def _get_db_override():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _get_db_override
yield session
app.dependency_overrides.pop(get_db, None)
session.close()
# ── Tests ─────────────────────────────────────────────────────
class TestListApiKeys:
"""Contract tests for GET /api/admin/api-keys."""
# #region test_list_empty [C:2] [TYPE Function]
# @BRIEF No keys → returns empty list.
def test_list_empty(self, client, mock_db):
response = client.get("/api/admin/api-keys/")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 0
# #endregion test_list_empty
# #region test_list_with_keys [C:2] [TYPE Function]
# @BRIEF Keys exist → returns list without raw_key or key_hash fields.
def test_list_with_keys(self, client, mock_db):
from src.models.api_key import APIKey
from src.core.auth.api_key import generate_api_key
# Create a key
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Test Key",
permissions=["maintenance:start", "maintenance:end"],
active=True,
)
mock_db.add(api_key)
mock_db.commit()
response = client.get("/api/admin/api-keys/")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "Test Key"
assert data[0]["prefix"] == prefix
assert "raw_key" not in data[0]
assert "key_hash" not in data[0]
# #endregion test_list_with_keys
class TestCreateApiKey:
"""Contract tests for POST /api/admin/api-keys."""
# #region test_create_valid [C:2] [TYPE Function]
# @BRIEF Valid request → 201 with raw_key, prefix, id.
def test_create_valid(self, client, mock_db):
response = client.post(
"/api/admin/api-keys/",
json={
"name": "CI/CD Pipeline",
"permissions": ["maintenance:start", "maintenance:end"],
"environment_id": "prod-env-1",
},
)
assert response.status_code == 201
data = response.json()
assert "id" in data
assert "raw_key" in data
assert data["raw_key"].startswith("ssk_")
assert data["prefix"] == data["raw_key"][:11]
assert data["name"] == "CI/CD Pipeline"
assert data["permissions"] == ["maintenance:start", "maintenance:end"]
assert data["active"] is True
assert data["environment_id"] == "prod-env-1"
# Verify raw key is NOT in DB
from src.models.api_key import APIKey
db_key = mock_db.query(APIKey).filter(APIKey.id == data["id"]).first()
assert db_key is not None
assert db_key.key_hash != data["raw_key"]
assert db_key.prefix == data["prefix"]
# #endregion test_create_valid
# #region test_create_missing_name [C:2] [TYPE Function]
# @BRIEF Missing name → 400 or 422.
def test_create_missing_name(self, client, mock_db):
response = client.post(
"/api/admin/api-keys/",
json={
"permissions": ["maintenance:start"],
},
)
assert response.status_code in (400, 422)
# #endregion test_create_missing_name
# #region test_create_empty_permissions [C:2] [TYPE Function]
# @BRIEF Empty permissions → 400 or 422.
def test_create_empty_permissions(self, client, mock_db):
response = client.post(
"/api/admin/api-keys/",
json={
"name": "No Perms",
"permissions": [],
},
)
assert response.status_code in (400, 422)
# #endregion test_create_empty_permissions
class TestRevokeApiKey:
"""Contract tests for DELETE /api/admin/api-keys/{id}."""
# #region test_revoke_valid [C:2] [TYPE Function]
# @BRIEF Revoke existing active key → 200 with status=revoked.
def test_revoke_valid(self, client, mock_db):
from src.models.api_key import APIKey
from src.core.auth.api_key import generate_api_key
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="To Revoke",
permissions=["maintenance:start"],
active=True,
)
mock_db.add(api_key)
mock_db.commit()
response = client.delete(f"/api/admin/api-keys/{api_key.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == api_key.id
assert data["status"] == "revoked"
# Verify DB
mock_db.refresh(api_key)
assert api_key.active is False
# #endregion test_revoke_valid
# #region test_revoke_already_revoked [C:2] [TYPE Function]
# @BRIEF Revoke already revoked key → 404.
def test_revoke_already_revoked(self, client, mock_db):
from src.models.api_key import APIKey
from src.core.auth.api_key import generate_api_key
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Already Revoked",
permissions=["maintenance:start"],
active=False, # already revoked
)
mock_db.add(api_key)
mock_db.commit()
response = client.delete(f"/api/admin/api-keys/{api_key.id}")
assert response.status_code == 404
# #endregion test_revoke_already_revoked
# #region test_revoke_not_found [C:2] [TYPE Function]
# @BRIEF Revoke non-existent key → 404.
def test_revoke_not_found(self, client, mock_db):
response = client.delete("/api/admin/api-keys/nonexistent-id")
assert response.status_code == 404
# #endregion test_revoke_not_found
# #endregion TestAPIKeyRoutes

View File

@@ -41,16 +41,29 @@ def client():
return TestClient(app)
def _mock_auth_overrides():
"""Apply dependency overrides for authentication.
Creates a mock admin user so that all RBAC checks pass.
The Admin role has full access via has_permission()'s special case.
"""
@pytest.fixture(autouse=True)
def auth_mock(client, mock_db):
"""Create a valid API key with maintenance permissions and mock JWT user."""
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
from src.dependencies import get_current_user
from src.app import app
# Create admin user with Admin role (full access)
# Create API key for mutation endpoints
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Test API Key",
permissions=["maintenance:start", "maintenance:end", "maintenance:end_all"],
active=True,
)
mock_db.add(api_key)
mock_db.commit()
client.headers.update({"X-API-Key": raw})
# Mock JWT user for read-only endpoints
mock_role = MagicMock()
mock_role.name = "Admin"
mock_role.permissions = []
@@ -64,19 +77,10 @@ def _mock_auth_overrides():
app.dependency_overrides[get_current_user] = _mock_get_current_user
def _clear_overrides():
"""Clear dependency overrides."""
from src.app import app
app.dependency_overrides = {}
@pytest.fixture(autouse=True)
def auth_mock():
"""Auto-apply auth mocks for all tests."""
_mock_auth_overrides()
yield
_clear_overrides()
client.headers.pop("X-API-Key", None)
app.dependency_overrides = {}
@pytest.fixture
@@ -164,6 +168,7 @@ class TestStartEndpoint:
"start_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"end_time": (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat(),
"message": "Test maintenance",
"environment_id": "test-env",
},
)
assert response.status_code == 202
@@ -194,6 +199,7 @@ class TestStartEndpoint:
"tables": ["raw.sales"],
"start_time": (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat(),
"end_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"environment_id": "test-env",
},
)
assert response.status_code == 400
@@ -208,6 +214,7 @@ class TestStartEndpoint:
json={
"tables": tables,
"start_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"environment_id": "test-env",
},
)
assert response.status_code == 422
@@ -222,6 +229,7 @@ class TestStartEndpoint:
"tables": ["raw.sales"],
"start_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"end_time": (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat(),
"environment_id": "test-env",
}
# First call creates event