Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
150 changes: 150 additions & 0 deletions tests/test_dependencies.py
Original file line number Diff line number Diff line change
@@ -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"
171 changes: 171 additions & 0 deletions tests/test_moderation.py
Original file line number Diff line number Diff line change
@@ -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()
Loading