"""Layer 11: Security and input validation tests. Verifies that the API: - Returns 401 for missing or invalid FiltrAuthentication header when a key is configured - Returns 200 on /health without any API key (health is exempt from auth) - Returns 422 for out-of-range query parameters (limit/offset) - Returns 422 for invalid path parameter types - Returns 422 for missing required request body fields - Handles SQL injection attempts safely (parameterised queries) - Returns appropriate errors for boundary conditions (sync_id=0, negative ids) - Does not leak internal details in error responses """ import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from sqlmodel import SQLModel from playlist_sync.api import app from playlist_sync.config import settings from playlist_sync.models.application import Application from playlist_sync.models.service_account import ServiceAccount # noqa: F401 from playlist_sync.models.sync_log import PlaylistSynchronizationLog # noqa: F401 from playlist_sync.models.sync_task import PlaylistSynchronization # noqa: F401 from playlist_sync.services.application_service import ApplicationService from playlist_sync.services.database import get_session TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture(scope="module") async def sec_engine(): engine = create_async_engine( TEST_DATABASE_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool, ) async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) yield engine async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.drop_all) await engine.dispose() @pytest_asyncio.fixture(scope="module") async def sec_client(sec_engine): Session = sessionmaker(sec_engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: session.add( Application( id=1, name="US", spotify_region_code="US", active=True, fallback_application=False, global_push_application=False, workout_market=False, ) ) await session.commit() ApplicationService.invalidate_cache() async def override_get_session(): async with Session() as session: yield session app.dependency_overrides[get_session] = override_get_session async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac app.dependency_overrides.clear() ApplicationService.invalidate_cache() # --------------------------------------------------------------------------- # Query parameter range validation # --------------------------------------------------------------------------- class TestQueryParameterValidation: async def test_limit_below_minimum_returns_422(self, sec_client): """limit=0 violates ge=1 constraint -> 422 Unprocessable Entity.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1/log?limit=0") assert resp.status_code == 422 async def test_limit_above_maximum_returns_422(self, sec_client): """limit=1001 violates le=1000 constraint -> 422.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1/log?limit=1001") assert resp.status_code == 422 async def test_limit_at_minimum_is_valid(self, sec_client): """limit=1 is the minimum allowed value - should not return 422.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1/log?limit=1") assert resp.status_code != 422 async def test_limit_at_maximum_is_valid(self, sec_client): """limit=1000 is the maximum allowed value.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1/log?limit=1000") assert resp.status_code != 422 async def test_negative_offset_returns_422(self, sec_client): """offset=-1 violates ge=0 constraint -> 422.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1/log?offset=-1") assert resp.status_code == 422 async def test_zero_offset_is_valid(self, sec_client): """offset=0 is the minimum allowed value.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1/log?offset=0") assert resp.status_code != 422 async def test_non_integer_limit_returns_422(self, sec_client): """Non-integer limit should return 422.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1/log?limit=abc") assert resp.status_code == 422 async def test_non_integer_offset_returns_422(self, sec_client): """Non-integer offset should return 422.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1/log?offset=xyz") assert resp.status_code == 422 # --------------------------------------------------------------------------- # Path parameter type validation # --------------------------------------------------------------------------- class TestPathParameterValidation: async def test_non_integer_sync_id_returns_422(self, sec_client): """Providing a non-integer sync_id in the path -> 422.""" resp = await sec_client.get("/PlaylistSync/US/playlists/not-an-id") assert resp.status_code == 422 async def test_float_sync_id_returns_422(self, sec_client): """Float sync_id should not be accepted.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1.5") assert resp.status_code == 422 async def test_missing_sync_returns_404_not_500(self, sec_client): """A large but valid int sync_id that does not exist -> 404, not 500.""" resp = await sec_client.get("/PlaylistSync/US/playlists/999999") assert resp.status_code == 404 async def test_zero_sync_id_returns_404_not_500(self, sec_client): """sync_id=0 is a valid integer but no sync should exist with id=0 -> 404.""" resp = await sec_client.get("/PlaylistSync/US/playlists/0") assert resp.status_code == 404 async def test_negative_sync_id_returns_404_not_500(self, sec_client): """Negative sync_id should return 404, not an unhandled 500.""" resp = await sec_client.get("/PlaylistSync/US/playlists/-1") assert resp.status_code == 404 # --------------------------------------------------------------------------- # Request body validation (POST / PUT) # --------------------------------------------------------------------------- class TestRequestBodyValidation: async def test_missing_required_field_from_playlist_id(self, sec_client): """POST without from_playlist_id -> 422.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "to_playlist_id": "target-001", "to_service_account_id": 1, }, ) assert resp.status_code == 422 async def test_missing_required_field_to_playlist_id(self, sec_client): """POST without to_playlist_id -> 422.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-001", "to_service_account_id": 1, }, ) assert resp.status_code == 422 async def test_missing_required_field_account_id(self, sec_client): """POST without to_service_account_id -> 422.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-001", "to_playlist_id": "target-001", }, ) assert resp.status_code == 422 async def test_empty_body_returns_422(self, sec_client): """POST with empty body -> 422.""" resp = await sec_client.post("/PlaylistSync/US/playlists", json={}) assert resp.status_code == 422 async def test_wrong_type_account_id_returns_422(self, sec_client): """to_service_account_id must be an integer.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "src", "to_playlist_id": "tgt", "to_service_account_id": "not-an-int", }, ) assert resp.status_code == 422 async def test_valid_minimal_body_accepted(self, sec_client): """POST with all required fields and a unique to_playlist_id returns 201.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-sec-001", "to_playlist_id": "target-sec-001", "to_service_account_id": 1, }, ) # 201 Created; not a 422 validation error assert resp.status_code == 201 async def test_extra_fields_in_body_are_ignored(self, sec_client): """Pydantic ignores unknown fields - should not return 422.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-sec-002", "to_playlist_id": "target-sec-002", "to_service_account_id": 1, "unknown_field": "should_be_ignored", "another_unknown": 42, }, ) assert resp.status_code not in (422,) async def test_boolean_field_accepts_true_and_false(self, sec_client): """append_track_list is optional bool; both True and False must be accepted.""" for val in (True, False): resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": f"source-bool-{val}", "to_playlist_id": f"target-bool-{val}", "to_service_account_id": 1, "append_track_list": val, }, ) assert resp.status_code != 422, f"Failed for append_track_list={val}" # --------------------------------------------------------------------------- # SQL injection prevention # --------------------------------------------------------------------------- class TestSqlInjectionPrevention: async def test_sql_injection_in_to_playlist_id_does_not_crash(self, sec_client): """SQL injection in to_playlist_id should be stored safely, not crash.""" injection_payload = "'; DROP TABLE tblPlaylistSynchronization; --" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-inject", "to_playlist_id": injection_payload, "to_service_account_id": 1, }, ) # Should be 201 (created safely) or 409 (conflict) - never 500 assert resp.status_code != 500 async def test_sql_injection_in_from_playlist_id_does_not_crash(self, sec_client): """SQL injection in from_playlist_id stored safely via parameterised query.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "1 OR 1=1; --", "to_playlist_id": "target-inject-002", "to_service_account_id": 1, }, ) assert resp.status_code != 500 async def test_sql_injection_in_country_code_returns_400_not_500(self, sec_client): """SQL injection in country_code path param -> 400 (unknown market), not 500.""" resp = await sec_client.get("/PlaylistSync/OR-1%3D1/playlists") assert resp.status_code in (400, 422) assert resp.status_code != 500 async def test_injection_in_title_stored_safely(self, sec_client): """SQL injection in title field stored safely as plain string.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-inject-title", "to_playlist_id": "target-inject-title", "to_service_account_id": 1, "title": "'; SELECT * FROM tblServiceAccount; --", }, ) assert resp.status_code != 500 async def test_stored_injection_is_readable_as_plain_string(self, sec_client): """Injected strings that are stored should be retrievable as literal strings.""" injection = "'; DELETE FROM tblPlaylistSynchronization; --" post_resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-inject-read", "to_playlist_id": "target-inject-read", "to_service_account_id": 1, "title": injection, }, ) assert post_resp.status_code == 201 # Verify the title was stored verbatim (as plain text, not executed) sync_id = post_resp.json()["id"] get_resp = await sec_client.get(f"/PlaylistSync/US/playlists/{sync_id}") assert get_resp.status_code == 200 assert get_resp.json()["title"] == injection # --------------------------------------------------------------------------- # Error response shape (no internal details leaked) # --------------------------------------------------------------------------- class TestErrorResponseShape: async def test_404_response_has_detail_key(self, sec_client): """404 responses should use the standard FastAPI {'detail': '...'} shape.""" resp = await sec_client.get("/PlaylistSync/US/playlists/999999") assert resp.status_code == 404 body = resp.json() assert "detail" in body async def test_422_response_has_detail_key(self, sec_client): """422 responses should include a 'detail' key with validation errors.""" resp = await sec_client.get("/PlaylistSync/US/playlists/1/log?limit=0") assert resp.status_code == 422 body = resp.json() assert "detail" in body async def test_400_response_has_detail_key(self, sec_client): """400 responses (unknown market) should have a 'detail' key.""" resp = await sec_client.get("/PlaylistSync/UNKNOWN_CODE/playlists") assert resp.status_code == 400 body = resp.json() assert "detail" in body async def test_404_does_not_leak_traceback(self, sec_client): """Error responses must not contain Python tracebacks.""" resp = await sec_client.get("/PlaylistSync/US/playlists/999999") text = resp.text assert "Traceback" not in text assert 'File "' not in text async def test_unknown_route_returns_404(self, sec_client): """Requests to undefined routes return 404.""" resp = await sec_client.get("/NonExistentEndpoint") assert resp.status_code == 404 async def test_405_on_wrong_http_method(self, sec_client): """Using PATCH (unsupported) on the health endpoint -> 405.""" resp = await sec_client.patch("/health") assert resp.status_code in (404, 405) # --------------------------------------------------------------------------- # Oversized / boundary value requests # --------------------------------------------------------------------------- class TestOversizedRequests: async def test_very_long_playlist_id_accepted(self, sec_client): """A 2000-character playlist ID string should not cause a 500.""" long_id = "x" * 2000 resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": long_id, "to_playlist_id": "target-long-001", "to_service_account_id": 1, }, ) # May succeed (201) or fail gracefully (400/422) - never 500 assert resp.status_code != 500 async def test_very_long_title_accepted(self, sec_client): """A 5000-character title should not cause a 500.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-long-title", "to_playlist_id": "target-long-title", "to_service_account_id": 1, "title": "T" * 5000, }, ) assert resp.status_code != 500 async def test_unicode_in_title_accepted(self, sec_client): """Unicode characters (emoji, CJK, Arabic) in title should be stored safely.""" unicode_title = "Playlist \u4e2d\u6587 \u0639\u0631\u0628\u064a \U0001f3b5" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-unicode", "to_playlist_id": "target-unicode", "to_service_account_id": 1, "title": unicode_title, }, ) assert resp.status_code != 500 async def test_null_optional_fields_accepted(self, sec_client): """Explicitly null optional fields should be accepted.""" resp = await sec_client.post( "/PlaylistSync/US/playlists", json={ "from_playlist_id": "source-null-fields", "to_playlist_id": "target-null-fields", "to_service_account_id": 1, "title": None, "description": None, }, ) assert resp.status_code not in (422, 500) # --------------------------------------------------------------------------- # API key authentication # --------------------------------------------------------------------------- _TEST_API_KEY = "test-secret-key-for-auth-tests" @pytest_asyncio.fixture async def auth_engine(): engine = create_async_engine( TEST_DATABASE_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool, ) async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) yield engine async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.drop_all) await engine.dispose() @pytest_asyncio.fixture async def auth_client(auth_engine, monkeypatch): """Client fixture with FILTR_API_KEY set to _TEST_API_KEY.""" monkeypatch.setattr(settings, "FILTR_API_KEY", _TEST_API_KEY) Session = sessionmaker(auth_engine, class_=AsyncSession, expire_on_commit=False) async with Session() as session: session.add( Application( id=1, name="US", spotify_region_code="US", active=True, fallback_application=False, global_push_application=False, workout_market=False, ) ) await session.commit() ApplicationService.invalidate_cache() async def override_get_session(): async with Session() as session: yield session app.dependency_overrides[get_session] = override_get_session async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac app.dependency_overrides.clear() ApplicationService.invalidate_cache() class TestApiKeyAuthentication: async def test_missing_header_returns_401(self, auth_client): """Request without FiltrAuthentication header -> 401.""" resp = await auth_client.get("/PlaylistSync/playlists") assert resp.status_code == 401 async def test_wrong_key_returns_401(self, auth_client): """Request with an incorrect key -> 401.""" resp = await auth_client.get( "/PlaylistSync/playlists", headers={"FiltrAuthentication": "wrong-key"}, ) assert resp.status_code == 401 async def test_correct_key_is_accepted(self, auth_client): """Request with the correct key -> not 401.""" resp = await auth_client.get( "/PlaylistSync/playlists", headers={"FiltrAuthentication": _TEST_API_KEY}, ) assert resp.status_code != 401 async def test_empty_key_header_returns_401(self, auth_client): """Empty FiltrAuthentication value -> 401.""" resp = await auth_client.get( "/PlaylistSync/playlists", headers={"FiltrAuthentication": ""}, ) assert resp.status_code == 401 async def test_401_response_has_detail_key(self, auth_client): """401 response must include the standard {'detail': '...'} shape.""" resp = await auth_client.get("/PlaylistSync/playlists") assert resp.status_code == 401 body = resp.json() assert "detail" in body async def test_health_endpoint_exempt_from_auth(self, auth_client): """/health must be reachable without any FiltrAuthentication header.""" resp = await auth_client.get("/health") # May be 200 or 503 (Redis down in test env) but never 401 assert resp.status_code != 401