mantimon-tcg/backend/tests/api/test_collections_api.py
Cal Corum 3ec670753b Fix security and validation issues from code review
Critical fixes:
- Add admin API key authentication for admin endpoints
- Add race condition protection via unique partial index for starter decks
- Make starter deck selection atomic with combined method

Moderate fixes:
- Fix DI pattern violation in validate_deck_endpoint
- Add card ID format validation (regex pattern)
- Add card quantity validation (1-99 range)
- Fix exception chaining with from None (B904)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:16:07 -06:00

360 lines
12 KiB
Python

"""Tests for collections API endpoints.
Tests the card collection management endpoints with mocked services.
"""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI, status
from fastapi.testclient import TestClient
from app.api import deps as api_deps
from app.api.collections import router as collections_router
from app.db.models import User
from app.db.models.collection import CardSource
from app.repositories.protocols import CollectionEntry
from app.services.card_service import CardService
from app.services.collection_service import CollectionService
from app.services.jwt_service import create_access_token
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def test_user():
"""Create a test user object."""
user = User(
email="test@example.com",
display_name="Test User",
avatar_url="https://example.com/avatar.jpg",
oauth_provider="google",
oauth_id="google-123",
is_premium=False,
premium_until=None,
)
user.id = uuid4()
user.created_at = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
user.last_login = None
user.linked_accounts = []
return user
@pytest.fixture
def access_token(test_user):
"""Create a valid access token for the test user."""
return create_access_token(test_user.id)
@pytest.fixture
def auth_headers(access_token):
"""Create Authorization headers with Bearer token."""
return {"Authorization": f"Bearer {access_token}"}
@pytest.fixture
def mock_collection_service():
"""Create a mock CollectionService."""
return MagicMock(spec=CollectionService)
@pytest.fixture
def mock_card_service():
"""Create a mock CardService."""
service = MagicMock(spec=CardService)
service.get_card.return_value = MagicMock() # Card exists
return service
@pytest.fixture
def app(test_user, mock_collection_service, mock_card_service):
"""Create a test FastAPI app with mocked dependencies."""
test_app = FastAPI()
test_app.include_router(collections_router, prefix="/api")
# Override dependencies
async def override_get_current_user():
return test_user
def override_get_collection_service():
return mock_collection_service
test_app.dependency_overrides[api_deps.get_current_user] = override_get_current_user
test_app.dependency_overrides[api_deps.get_collection_service] = override_get_collection_service
yield test_app
test_app.dependency_overrides.clear()
@pytest.fixture
def unauthenticated_app():
"""Create a test FastAPI app without auth override (for 401 tests)."""
test_app = FastAPI()
test_app.include_router(collections_router, prefix="/api")
yield test_app
@pytest.fixture
def client(app):
"""Create a test client for the app."""
return TestClient(app)
@pytest.fixture
def unauthenticated_client(unauthenticated_app):
"""Create a test client without auth for 401 tests."""
return TestClient(unauthenticated_app)
def make_collection_entry(
card_id: str = "a1-001-bulbasaur",
quantity: int = 4,
source: CardSource = CardSource.BOOSTER,
) -> CollectionEntry:
"""Create a CollectionEntry for testing."""
return CollectionEntry(
id=uuid4(),
user_id=uuid4(),
card_definition_id=card_id,
quantity=quantity,
source=source,
obtained_at=datetime.now(UTC),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
# =============================================================================
# GET /collections/me Tests
# =============================================================================
class TestGetMyCollection:
"""Tests for GET /api/collections/me endpoint."""
def test_returns_empty_collection_for_new_user(
self, client: TestClient, auth_headers, mock_collection_service
):
"""
Test that endpoint returns empty collection for new user.
New users should have no cards until they select a starter.
"""
mock_collection_service.get_collection = AsyncMock(return_value=[])
response = client.get("/api/collections/me", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["total_unique_cards"] == 0
assert data["total_card_count"] == 0
assert data["entries"] == []
def test_returns_collection_with_cards(
self, client: TestClient, auth_headers, mock_collection_service
):
"""
Test that endpoint returns cards in collection.
Should include all cards with quantities and totals.
"""
entries = [
make_collection_entry("a1-001-bulbasaur", 4),
make_collection_entry("a1-033-charmander", 2),
]
mock_collection_service.get_collection = AsyncMock(return_value=entries)
response = client.get("/api/collections/me", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["total_unique_cards"] == 2
assert data["total_card_count"] == 6 # 4 + 2
assert len(data["entries"]) == 2
def test_requires_authentication(self, unauthenticated_client: TestClient):
"""
Test that endpoint requires authentication.
Unauthenticated requests should return 401.
"""
response = unauthenticated_client.get("/api/collections/me")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# =============================================================================
# GET /collections/me/cards/{card_id} Tests
# =============================================================================
class TestGetCardQuantity:
"""Tests for GET /api/collections/me/cards/{card_id} endpoint."""
def test_returns_card_quantity(self, client: TestClient, auth_headers, mock_collection_service):
"""
Test that endpoint returns quantity for owned card.
Should return the card ID and quantity.
"""
mock_collection_service.get_card_quantity = AsyncMock(return_value=4)
response = client.get("/api/collections/me/cards/a1-001-bulbasaur", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["card_definition_id"] == "a1-001-bulbasaur"
assert data["quantity"] == 4
def test_returns_404_for_unowned_card(
self, client: TestClient, auth_headers, mock_collection_service
):
"""
Test that endpoint returns 404 for cards not in collection.
Cards with quantity 0 should return 404.
"""
mock_collection_service.get_card_quantity = AsyncMock(return_value=0)
response = client.get("/api/collections/me/cards/unowned-card", headers=auth_headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_requires_authentication(self, unauthenticated_client: TestClient):
"""
Test that endpoint requires authentication.
Unauthenticated requests should return 401.
"""
response = unauthenticated_client.get("/api/collections/me/cards/a1-001-bulbasaur")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# =============================================================================
# POST /collections/admin/{user_id}/add Tests
# =============================================================================
class TestAdminAddCards:
"""Tests for POST /api/collections/admin/{user_id}/add endpoint.
Admin endpoint requires X-Admin-API-Key header instead of user authentication.
"""
@pytest.fixture
def admin_app(self, mock_collection_service):
"""Create a test app with admin auth bypassed."""
from app.api import deps as api_deps
test_app = FastAPI()
test_app.include_router(collections_router, prefix="/api")
# Override admin auth to allow access
async def override_verify_admin_token():
return None
def override_get_collection_service():
return mock_collection_service
test_app.dependency_overrides[api_deps.verify_admin_token] = override_verify_admin_token
test_app.dependency_overrides[api_deps.get_collection_service] = (
override_get_collection_service
)
yield test_app
test_app.dependency_overrides.clear()
@pytest.fixture
def admin_client(self, admin_app):
"""Create a test client with admin auth bypassed."""
return TestClient(admin_app)
def test_adds_cards_to_collection(self, admin_client: TestClient, mock_collection_service):
"""
Test that admin can add cards to user's collection.
Should create/update collection entry and return it.
"""
entry = make_collection_entry("a1-001-bulbasaur", 5, CardSource.GIFT)
mock_collection_service.add_cards = AsyncMock(return_value=entry)
user_id = str(uuid4())
response = admin_client.post(
f"/api/collections/admin/{user_id}/add",
json={
"card_definition_id": "a1-001-bulbasaur",
"quantity": 5,
"source": "gift",
},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["card_definition_id"] == "a1-001-bulbasaur"
assert data["quantity"] == 5
def test_returns_400_for_invalid_card(self, admin_client: TestClient, mock_collection_service):
"""
Test that endpoint returns 400 for invalid card ID.
Non-existent card IDs should be rejected.
"""
mock_collection_service.add_cards = AsyncMock(side_effect=ValueError("Invalid card ID"))
user_id = str(uuid4())
response = admin_client.post(
f"/api/collections/admin/{user_id}/add",
json={
"card_definition_id": "invalid-card",
"quantity": 1,
"source": "gift",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_requires_admin_api_key(self, unauthenticated_client: TestClient):
"""
Test that endpoint returns 401 without admin API key header.
Requests without X-Admin-API-Key header are rejected with 401.
"""
user_id = str(uuid4())
response = unauthenticated_client.post(
f"/api/collections/admin/{user_id}/add",
json={
"card_definition_id": "a1-001-bulbasaur",
"quantity": 1,
"source": "gift",
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "not authenticated" in response.json()["detail"].lower()
def test_rejects_invalid_admin_api_key(self, unauthenticated_client: TestClient):
"""
Test that endpoint returns 403 for invalid admin API key.
Requests with wrong X-Admin-API-Key are rejected with 403.
"""
user_id = str(uuid4())
response = unauthenticated_client.post(
f"/api/collections/admin/{user_id}/add",
headers={"X-Admin-API-Key": "wrong-key"},
json={
"card_definition_id": "a1-001-bulbasaur",
"quantity": 1,
"source": "gift",
},
)
# Returns 403 because the key is invalid (or not configured in test env)
assert response.status_code == status.HTTP_403_FORBIDDEN