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:
500
backend/tests/test_api_key_auth.py
Normal file
500
backend/tests/test_api_key_auth.py
Normal 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
|
||||
122
backend/tests/test_api_key_model.py
Normal file
122
backend/tests/test_api_key_model.py
Normal 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
|
||||
258
backend/tests/test_api_key_routes.py
Normal file
258
backend/tests/test_api_key_routes.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user