diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..46cb9ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + quality-checks: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run ruff check + run: uv run ruff check src/ tests/ + + - name: Run ruff format check + run: uv run ruff format --check src/ tests/ + + - name: Run mypy type checking + run: uv run mypy src/ --strict + + - name: Run tests with coverage + run: uv run pytest --cov=src --cov-report=term-missing --cov-report=xml --cov-fail-under=80 + + - name: Upload coverage reports + uses: codecov/codecov-action@v4 + if: always() + with: + file: ./coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + security-audit: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run pip-audit + run: uv run pip-audit + continue-on-error: true diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..b4d6b55 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,150 @@ +"""Tests for web dependencies (authentication and authorization).""" + +from unittest.mock import Mock, patch + +import pytest +from fastapi import Request +from fastapi.responses import RedirectResponse +from starlette.exceptions import HTTPException + +from src.config import Settings +from src.web.dependencies import ( + AuthenticationRequired, + auth_exception_handler, + get_current_user, + require_admin, + require_auth, +) + + +class TestAuthenticationRequired: + """Tests for AuthenticationRequired exception.""" + + def test_exception_has_correct_status_code(self): + """Test AuthenticationRequired sets 303 status code.""" + exc = AuthenticationRequired() + assert exc.status_code == 303 + assert exc.detail == "Authentication required" + + +class TestGetCurrentUser: + """Tests for get_current_user dependency.""" + + @patch("src.web.dependencies.get_current_user_from_cookie") + def test_get_current_user_returns_user_dict_when_authenticated(self, mock_get_user): + """Test get_current_user returns user dict from cookie.""" + mock_get_user.return_value = {"user_id": "test-id", "email": "test@example.com"} + request = Mock(spec=Request) + + result = get_current_user(request) + + assert result is not None + assert result["user_id"] == "test-id" + mock_get_user.assert_called_once_with(request) + + @patch("src.web.dependencies.get_current_user_from_cookie") + def test_get_current_user_returns_none_when_not_authenticated(self, mock_get_user): + """Test get_current_user returns None when no session cookie.""" + mock_get_user.return_value = None + request = Mock(spec=Request) + + result = get_current_user(request) + + assert result is None + mock_get_user.assert_called_once_with(request) + + +class TestRequireAuth: + """Tests for require_auth dependency.""" + + def test_require_auth_returns_none_when_auth_disabled(self): + """Test require_auth returns None when auth is disabled.""" + request = Mock(spec=Request) + settings = Settings(auth_enabled=False, jwt_secret_key="") + + result = require_auth(request, settings) + + assert result is None + + def test_require_auth_returns_none_when_no_jwt_secret(self): + """Test require_auth returns None when JWT secret is not configured.""" + request = Mock(spec=Request) + settings = Settings(auth_enabled=True, jwt_secret_key="") + + result = require_auth(request, settings) + + assert result is None + + @patch("src.web.dependencies.get_current_user_from_cookie") + def test_require_auth_returns_user_when_authenticated(self, mock_get_user): + """Test require_auth returns user dict when authenticated.""" + mock_get_user.return_value = { + "user_id": "test-id", + "email": "test@example.com", + "is_admin": False, + } + request = Mock(spec=Request) + settings = Settings(auth_enabled=True, jwt_secret_key="secret-key-123") + + result = require_auth(request, settings) + + assert result is not None + assert result["user_id"] == "test-id" + mock_get_user.assert_called_once_with(request) + + @patch("src.web.dependencies.get_current_user_from_cookie") + def test_require_auth_raises_when_not_authenticated(self, mock_get_user): + """Test require_auth raises AuthenticationRequired when not authenticated.""" + mock_get_user.return_value = None + request = Mock(spec=Request) + settings = Settings(auth_enabled=True, jwt_secret_key="secret-key-123") + + with pytest.raises(AuthenticationRequired): + require_auth(request, settings) + + +class TestRequireAdmin: + """Tests for require_admin dependency.""" + + def test_require_admin_returns_none_when_auth_disabled(self): + """Test require_admin returns None when auth is disabled (user is None).""" + result = require_admin(user=None) + + assert result is None + + def test_require_admin_returns_user_when_user_is_admin(self): + """Test require_admin returns user dict when user is admin.""" + user = {"user_id": "admin-id", "email": "admin@example.com", "is_admin": True} + + result = require_admin(user=user) + + assert result is not None + assert result["user_id"] == "admin-id" + assert result["is_admin"] is True + + def test_require_admin_raises_403_when_user_is_not_admin(self): + """Test require_admin raises 403 when user is not admin.""" + user = {"user_id": "user-id", "email": "user@example.com", "is_admin": False} + + with pytest.raises(HTTPException) as exc_info: + require_admin(user=user) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Admin access required" + + +class TestAuthExceptionHandler: + """Tests for auth_exception_handler.""" + + @pytest.mark.asyncio + async def test_auth_exception_handler_redirects_to_login(self): + """Test auth_exception_handler redirects to login with next parameter.""" + request = Mock(spec=Request) + request.url.path = "/protected/page" + exc = AuthenticationRequired() + + response = await auth_exception_handler(request, exc) + + assert isinstance(response, RedirectResponse) + assert response.status_code == 303 + assert response.headers["location"] == "/login?next=/protected/page" diff --git a/tests/test_moderation.py b/tests/test_moderation.py new file mode 100644 index 0000000..0641a1f --- /dev/null +++ b/tests/test_moderation.py @@ -0,0 +1,171 @@ +"""Tests for content moderation using OpenAI Moderation API.""" + +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from src.infrastructure.safety.moderation import NoOpModerator, OpenAIModerator +from src.infrastructure.safety.schemas import ModerationResult + + +class TestOpenAIModeratorInit: + """Tests for OpenAIModerator initialization.""" + + def test_init_with_valid_api_key(self): + """Test initialization with a valid API key.""" + moderator = OpenAIModerator(api_key="test-key-123") + assert moderator._api_key == "test-key-123" + assert moderator._timeout == 10.0 + + def test_init_with_custom_timeout(self): + """Test initialization with custom timeout.""" + moderator = OpenAIModerator(api_key="test-key", timeout_seconds=5.0) + assert moderator._timeout == 5.0 + + +class TestOpenAIModeratorCheck: + """Tests for OpenAIModerator.check method.""" + + @pytest.mark.asyncio + async def test_check_returns_safe_result_for_safe_content(self): + """Test check returns safe result for non-flagged content.""" + moderator = OpenAIModerator(api_key="test-key") + + # Mock the HTTP client + mock_response = Mock() + mock_response.json.return_value = { + "results": [ + { + "flagged": False, + "categories": {"hate": False, "violence": False}, + "category_scores": {"hate": 0.1, "violence": 0.05}, + } + ] + } + moderator._client.post = AsyncMock(return_value=mock_response) + + result = await moderator.check("This is safe content") + + assert isinstance(result, ModerationResult) + assert result.flagged is False + assert result.categories == {"hate": False, "violence": False} + + await moderator.close() + + @pytest.mark.asyncio + async def test_check_returns_flagged_result_for_unsafe_content(self): + """Test check returns flagged result for unsafe content.""" + moderator = OpenAIModerator(api_key="test-key") + + # Mock the HTTP client + mock_response = Mock() + mock_response.json.return_value = { + "results": [ + { + "flagged": True, + "categories": {"hate": True, "violence": False}, + "category_scores": {"hate": 0.9, "violence": 0.1}, + } + ] + } + moderator._client.post = AsyncMock(return_value=mock_response) + + result = await moderator.check("Unsafe content") + + assert isinstance(result, ModerationResult) + assert result.flagged is True + assert result.categories["hate"] is True + + await moderator.close() + + @pytest.mark.asyncio + async def test_check_handles_timeout_gracefully(self): + """Test check returns safe result on timeout (fail open).""" + moderator = OpenAIModerator(api_key="test-key") + + # Mock timeout exception + moderator._client.post = AsyncMock( + side_effect=httpx.TimeoutException("Timeout") + ) + + result = await moderator.check("Some text") + + # Should fail open (return safe result) + assert isinstance(result, ModerationResult) + assert result.flagged is False + assert result.categories == {} + + await moderator.close() + + @pytest.mark.asyncio + async def test_check_handles_http_error_gracefully(self): + """Test check returns safe result on HTTP error (fail open).""" + moderator = OpenAIModerator(api_key="test-key") + + # Mock HTTP error + mock_response = Mock() + mock_response.status_code = 500 + error = httpx.HTTPStatusError( + "Server error", request=Mock(), response=mock_response + ) + moderator._client.post = AsyncMock(side_effect=error) + + result = await moderator.check("Some text") + + # Should fail open (return safe result) + assert isinstance(result, ModerationResult) + assert result.flagged is False + assert result.categories == {} + + await moderator.close() + + @pytest.mark.asyncio + async def test_check_handles_unexpected_error_gracefully(self): + """Test check returns safe result on unexpected error (fail open).""" + moderator = OpenAIModerator(api_key="test-key") + + # Mock unexpected exception + moderator._client.post = AsyncMock(side_effect=ValueError("Unexpected error")) + + result = await moderator.check("Some text") + + # Should fail open (return safe result) + assert isinstance(result, ModerationResult) + assert result.flagged is False + assert result.categories == {} + + await moderator.close() + + @pytest.mark.asyncio + async def test_close_closes_http_client(self): + """Test close method closes the HTTP client.""" + moderator = OpenAIModerator(api_key="test-key") + moderator._client.aclose = AsyncMock() + + await moderator.close() + + moderator._client.aclose.assert_called_once() + + +class TestNoOpModerator: + """Tests for NoOpModerator.""" + + @pytest.mark.asyncio + async def test_check_always_returns_safe(self): + """Test NoOpModerator always returns safe result.""" + moderator = NoOpModerator() + + result = await moderator.check("Any text at all") + + assert isinstance(result, ModerationResult) + assert result.flagged is False + assert result.categories == {} + + @pytest.mark.asyncio + async def test_close_does_nothing(self): + """Test NoOpModerator close method is a no-op.""" + moderator = NoOpModerator() + + # Should not raise any exception + await moderator.close()