diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a4d1940..4c817c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,11 @@ jobs: name: Run Tests & Coverage runs-on: ubuntu-latest + env: + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/README.md b/README.md index f350717..e359847 100644 Binary files a/README.md and b/README.md differ diff --git a/backend/alembic/env.py b/backend/alembic/env.py index e69de29..e9ee08c 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -0,0 +1,120 @@ +""" +Alembic Environment Configuration for CodeGuard AI. + +Este archivo configura Alembic para detectar todos los modelos ORM +y generar migraciones automáticamente contra Supabase PostgreSQL. +""" + +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +# ------------------------------------------------------------------------ +# 1. Configuración de Rutas (Path) +# ------------------------------------------------------------------------ +# Agregamos el directorio padre (backend/) al path de Python +# para que Alembic pueda encontrar la carpeta 'src' +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# ------------------------------------------------------------------------ +# 2. Importación de Modelos y Configuración +# ------------------------------------------------------------------------ +# Importamos la Base declarativa y TODOS los modelos +# IMPORTANTE: Cada modelo debe ser importado para que Alembic lo detecte +from src.models import ( + Base, + UserEntity, + CodeReviewEntity, + AgentFindingEntity, + ReviewStatus, + SeverityEnum, + UserRole, +) + +# Configuración de la base de datos +# Intentamos cargar desde settings, con fallback a variables de entorno +try: + from src.core.config.settings import settings + db_url = settings.DATABASE_URL +except ImportError: + from dotenv import load_dotenv + load_dotenv() + db_url = os.getenv("DATABASE_URL") + +if not db_url: + raise ValueError( + "DATABASE_URL no está configurada. " + "Configúrala en .env o en src/core/config/settings.py" + ) + +# ------------------------------------------------------------------------ +# 3. Configuración de Alembic +# ------------------------------------------------------------------------ +config = context.config + +# Interpretar el archivo de configuración para el logging +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Asignar la metadata de los modelos para que Alembic pueda "ver" las tablas +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """ + Ejecuta migraciones en modo 'offline'. + + Configura el contexto con solo una URL, sin crear un Engine. + Útil para generar scripts SQL sin conexión a la BD. + """ + context.configure( + url=db_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + # Comparar tipos para detectar cambios en columnas + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """ + Ejecuta migraciones en modo 'online'. + + Crea un Engine y se conecta a la base de datos Supabase. + """ + # Obtenemos la configuración de alembic.ini + configuration = config.get_section(config.config_ini_section) + + # Inyectar la URL de la base de datos desde el entorno + configuration["sqlalchemy.url"] = db_url + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + # Comparar tipos para detectar cambios en columnas + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako index e69de29..04d5198 100644 --- a/backend/alembic/script.py.mako +++ b/backend/alembic/script.py.mako @@ -0,0 +1,33 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + + + + + + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/backend/alembic/versions/ba48c1bb8e18_create_initial_tables_users_code_.py b/backend/alembic/versions/ba48c1bb8e18_create_initial_tables_users_code_.py new file mode 100644 index 0000000..00d9d72 --- /dev/null +++ b/backend/alembic/versions/ba48c1bb8e18_create_initial_tables_users_code_.py @@ -0,0 +1,97 @@ +"""create_initial_tables_users_code_reviews_findings + +Revision ID: ba48c1bb8e18 +Revises: +Create Date: 2025-11-27 02:43:54.598631 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'ba48c1bb8e18' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('avatar_url', sa.String(length=500), nullable=True), + sa.Column('role', sa.Enum('DEVELOPER', 'ADMIN', name='userrole'), nullable=False), + sa.Column('daily_analysis_count', sa.Integer(), nullable=False), + sa.Column('last_analysis_date', sa.Date(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_role'), 'users', ['role'], unique=False) + op.create_table('code_reviews', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.String(length=255), nullable=False), + sa.Column('filename', sa.String(length=500), nullable=False), + sa.Column('code_content', sa.LargeBinary(), nullable=False), + sa.Column('quality_score', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', name='reviewstatus'), nullable=False), + sa.Column('total_findings', sa.Integer(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_code_reviews_created_at'), 'code_reviews', ['created_at'], unique=False) + op.create_index(op.f('ix_code_reviews_status'), 'code_reviews', ['status'], unique=False) + op.create_index(op.f('ix_code_reviews_user_id'), 'code_reviews', ['user_id'], unique=False) + op.create_table('agent_findings', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('review_id', sa.UUID(), nullable=False), + sa.Column('agent_type', sa.String(length=100), nullable=False), + sa.Column('severity', sa.Enum('CRITICAL', 'HIGH', 'MEDIUM', 'LOW', name='severityenum'), nullable=False), + sa.Column('issue_type', sa.String(length=200), nullable=False), + sa.Column('line_number', sa.Integer(), nullable=False), + sa.Column('code_snippet', sa.Text(), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('suggestion', sa.Text(), nullable=True), + sa.Column('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('ai_explanation', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('mcp_references', postgresql.ARRAY(sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['review_id'], ['code_reviews.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_agent_findings_agent_type'), 'agent_findings', ['agent_type'], unique=False) + op.create_index(op.f('ix_agent_findings_review_id'), 'agent_findings', ['review_id'], unique=False) + op.create_index(op.f('ix_agent_findings_severity'), 'agent_findings', ['severity'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + + + + + + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_agent_findings_severity'), table_name='agent_findings') + op.drop_index(op.f('ix_agent_findings_review_id'), table_name='agent_findings') + op.drop_index(op.f('ix_agent_findings_agent_type'), table_name='agent_findings') + op.drop_table('agent_findings') + op.drop_index(op.f('ix_code_reviews_user_id'), table_name='code_reviews') + op.drop_index(op.f('ix_code_reviews_status'), table_name='code_reviews') + op.drop_index(op.f('ix_code_reviews_created_at'), table_name='code_reviews') + op.drop_table('code_reviews') + op.drop_index(op.f('ix_users_role'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index e69de29..8fefd8d 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -0,0 +1,61 @@ +""" +Configuración centralizada para CodeGuard AI. + +Carga variables de entorno usando pydantic-settings. +""" + +from typing import Optional + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """ + Configuración de la aplicación cargada desde variables de entorno. + + Attributes: + CLERK_SECRET_KEY: Clave secreta de Clerk para validar JWT + CLERK_PUBLISHABLE_KEY: Clave pública de Clerk + DATABASE_URL: URL de conexión a PostgreSQL/Supabase + ENVIRONMENT: Entorno de ejecución (development/production) + DEBUG: Modo debug + """ + + # Clerk Authentication + CLERK_SECRET_KEY: str + CLERK_PUBLISHABLE_KEY: str + + # Database + DATABASE_URL: str + + # Application + ENVIRONMENT: str = "development" + DEBUG: bool = True + APP_NAME: str = "CodeGuard AI" + APP_VERSION: str = "1.0.0" + + # API + API_HOST: str = "0.0.0.0" + API_PORT: int = 8000 + + # CORS + ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:5173" + + # Redis (opcional para Sprint 2) + REDIS_URL: Optional[str] = None + REDIS_PASSWORD: Optional[str] = None + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + @property + def allowed_origins_list(self) -> list[str]: + """Retorna lista de orígenes permitidos para CORS.""" + return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")] + + +# Singleton de configuración +settings = Settings() diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 99b1b09..9e238cb 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -3,10 +3,11 @@ """ import os +from typing import Generator from dotenv import load_dotenv from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, sessionmaker load_dotenv() @@ -17,3 +18,22 @@ engine = create_engine(DATABASE_URL, pool_pre_ping=True) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db() -> Generator[Session, None, None]: + """ + Dependencia de FastAPI para obtener sesión de base de datos. + + Yields: + Session: Sesión de SQLAlchemy. + + Example: + @app.get("/users") + def get_users(db: Session = Depends(get_db)): + return db.query(User).all() + """ + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/src/core/dependencies/auth.py b/backend/src/core/dependencies/auth.py index c944bde..a607bb5 100644 --- a/backend/src/core/dependencies/auth.py +++ b/backend/src/core/dependencies/auth.py @@ -1,57 +1,99 @@ """ Dependencia de autenticación. -Provee OAuth2PasswordBearer para Swagger UI y autenticación opcional en desarrollo. +Valida tokens JWT de Clerk y protege rutas. """ -import os - from fastapi import Depends, HTTPException -from fastapi.security import OAuth2PasswordBearer +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from src.external.clerk_client import ( + ClerkClient, + ClerkTokenExpiredError, + ClerkTokenInvalidError, +) from src.schemas.user import Role, User -# OAuth2 scheme para Swagger UI - muestra botón "Authorize" -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token", auto_error=False) +# HTTPBearer para extraer token del header Authorization +http_bearer = HTTPBearer(auto_error=False) -async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(http_bearer), +) -> User: """ - Obtiene el usuario actual basado en el token. + Obtiene el usuario actual validando el token JWT de Clerk. - En desarrollo: retorna usuario mock. - En producción: valida token JWT (a implementar en Sprint 2). + Flujo: + 1. Extrae token del header Authorization: Bearer + 2. Valida el token con ClerkClient + 3. Retorna User schema con los datos del token Args: - token: Token JWT del header Authorization. + credentials: Credenciales HTTP Bearer. Returns: User: Usuario autenticado. Raises: - HTTPException: 401 si el token es inválido en producción. + HTTPException 401: Si el token falta, es inválido o expiró. """ - environment = os.getenv("ENVIRONMENT", "development") - - if environment == "production": - # En producción, validar token real - if not token: - raise HTTPException( - status_code=401, - detail="Token de autenticación requerido", - headers={"WWW-Authenticate": "Bearer"}, - ) - # TODO: Implementar validación real con Clerk en Sprint 2 + # AC Escenario 2: Verificar que el token esté presente + if not credentials: + raise HTTPException( + status_code=401, + detail="Token de autenticación requerido", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + clerk_client = ClerkClient() + + try: + # Validar token con Clerk + payload = clerk_client.verify_token(token) + + return User( + id=payload["user_id"], + email=payload.get("email", ""), + name=payload.get("name"), + role=Role.DEVELOPER, + ) + + except ClerkTokenExpiredError: + # AC Escenario 6: Token expirado + raise HTTPException( + status_code=401, + detail="Token expirado", + headers={"WWW-Authenticate": "Bearer"}, + ) + except ClerkTokenInvalidError: + # AC Escenario 5: Token inválido raise HTTPException( status_code=401, detail="Token inválido", headers={"WWW-Authenticate": "Bearer"}, ) - # En desarrollo, retornar usuario mock - return User( - id="user_123", - email="dev@codeguard.ai", - name="Developer User", - role=Role.DEVELOPER, - ) + +async def get_optional_user( + credentials: HTTPAuthorizationCredentials = Depends(http_bearer), +) -> User | None: + """ + Obtiene el usuario actual si hay token, None si no. + + Útil para endpoints que funcionan con o sin autenticación. + + Args: + credentials: Credenciales HTTP Bearer (opcional). + + Returns: + User si hay token válido, None si no hay token. + + Raises: + HTTPException 401: Si hay token pero es inválido o expiró. + """ + if not credentials: + return None + + return await get_current_user(credentials) diff --git a/backend/src/external/clerk_client.py b/backend/src/external/clerk_client.py index e69de29..19e167e 100644 --- a/backend/src/external/clerk_client.py +++ b/backend/src/external/clerk_client.py @@ -0,0 +1,97 @@ +""" +Cliente externo para validación de tokens JWT de Clerk. + +Abstrae la lógica de validación usando python-jose con algoritmo HS256. +""" + +from typing import Any, Dict + +from jose import ExpiredSignatureError, JWTError, jwt + +from src.core.config.settings import settings + + +class ClerkTokenError(Exception): + """Error base para problemas con tokens de Clerk.""" + + pass + + +class ClerkTokenExpiredError(ClerkTokenError): + """Token JWT expirado.""" + + pass + + +class ClerkTokenInvalidError(ClerkTokenError): + """Token JWT inválido o malformado.""" + + pass + + +class ClerkClient: + """ + Cliente para validar tokens JWT emitidos por Clerk. + + Utiliza el algoritmo HS256 con la CLERK_SECRET_KEY para + decodificar y validar tokens. + """ + + def __init__(self): + """Inicializa el cliente con la configuración de Clerk.""" + self._secret_key = settings.CLERK_SECRET_KEY + self._algorithms = ["HS256"] + + def verify_token(self, token: str) -> Dict[str, Any]: + """ + Valida un token JWT y retorna el payload decodificado. + + Args: + token: Token JWT a validar. + + Returns: + Dict con user_id, email, name extraídos del payload. + + Raises: + ClerkTokenExpiredError: Si el token ha expirado. + ClerkTokenInvalidError: Si el token es inválido o malformado. + """ + try: + payload = jwt.decode( + token, + self._secret_key, + algorithms=self._algorithms, + ) + + return { + "user_id": payload.get("sub"), + "email": payload.get("email"), + "name": payload.get("name"), + } + + except ExpiredSignatureError as e: + raise ClerkTokenExpiredError("El token ha expirado") from e + except JWTError as e: + raise ClerkTokenInvalidError("Token inválido o malformado") from e + + def get_user_id_from_token(self, token: str) -> str: + """ + Extrae solo el user_id del token. + + Args: + token: Token JWT. + + Returns: + User ID (sub claim). + + Raises: + ClerkTokenExpiredError: Si el token ha expirado. + ClerkTokenInvalidError: Si el token es inválido. + """ + payload = self.verify_token(token) + user_id = payload.get("user_id") + + if not user_id: + raise ClerkTokenInvalidError("Token no contiene user_id (sub)") + + return user_id diff --git a/backend/src/main.py b/backend/src/main.py index cecca13..3d6d83c 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -7,6 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware from src.routers.analysis import router as analysis_router +from src.routers.auth import router as auth_router # Create FastAPI app app = FastAPI( @@ -27,6 +28,7 @@ ) app.include_router(analysis_router) +app.include_router(auth_router) @app.get("/health") diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index e69de29..b91a00a 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -0,0 +1,28 @@ +""" +SQLAlchemy ORM Models para CodeGuard AI. + +Este módulo exporta todas las entidades de base de datos para facilitar imports. +""" + +from src.models.base import Base +from src.models.code_review import CodeReviewEntity + +# Enums +from src.models.enums.review_status import ReviewStatus +from src.models.enums.severity_enum import SeverityEnum +from src.models.enums.user_role import UserRole +from src.models.finding import AgentFindingEntity +from src.models.user import UserEntity + +__all__ = [ + # Base + "Base", + # Entities + "UserEntity", + "CodeReviewEntity", + "AgentFindingEntity", + # Enums + "ReviewStatus", + "SeverityEnum", + "UserRole", +] diff --git a/backend/src/models/base.py b/backend/src/models/base.py index a4bfede..cc1270d 100644 --- a/backend/src/models/base.py +++ b/backend/src/models/base.py @@ -17,6 +17,9 @@ class Base(DeclarativeBase): comparado con la función antigua `declarative_base()`. Todas las entidades (ej. `CodeReviewEntity`) deben heredar de esta clase. + + __allow_unmapped__ = True permite usar anotaciones de tipo sin Mapped[] + para mantener compatibilidad con el código existente. """ - pass + __allow_unmapped__ = True diff --git a/backend/src/models/code_review.py b/backend/src/models/code_review.py index 764c55b..42d7f7b 100644 --- a/backend/src/models/code_review.py +++ b/backend/src/models/code_review.py @@ -1,29 +1,84 @@ +""" +Entidad ORM para code reviews. +Alineado con tabla 'code_reviews' en PostgreSQL (Supabase). +""" + import uuid from datetime import datetime +from typing import TYPE_CHECKING, List -from sqlalchemy import Column, DateTime, Enum, Integer, LargeBinary, String +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, LargeBinary, String, Text from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship from src.models.base import Base from src.models.enums.review_status import ReviewStatus +if TYPE_CHECKING: + from src.models.finding import AgentFindingEntity + from src.models.user import UserEntity + class CodeReviewEntity(Base): """ Entidad ORM que representa la tabla 'code_reviews' en la base de datos. + + Attributes: + id: UUID del análisis + user_id: FK a users (Clerk user_id) + filename: Nombre del archivo analizado + code_content: Contenido encriptado con AES-256 (BYTEA) + quality_score: Puntuación de calidad (0-100) + status: PENDING, PROCESSING, COMPLETED, FAILED + total_findings: Número total de hallazgos + error_message: Mensaje de error si falló + created_at: Timestamp de creación + completed_at: Timestamp de finalización """ __tablename__ = "code_reviews" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id = Column(String, nullable=False, index=True) - filename = Column(String, nullable=False) + user_id = Column( + String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + filename = Column(String(500), nullable=False) # RN16: code_content se almacena como bytes encriptados (BYTEA) code_content = Column(LargeBinary, nullable=False) - quality_score = Column(Integer, nullable=False) - status = Column(Enum(ReviewStatus), nullable=False, index=True) + quality_score = Column(Integer, nullable=True) + status = Column(Enum(ReviewStatus), default=ReviewStatus.PENDING, nullable=False, index=True) total_findings = Column(Integer, default=0) + error_message = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, index=True) completed_at = Column(DateTime, nullable=True) + + # Relationships + user: "UserEntity" = relationship("UserEntity", back_populates="code_reviews") + + findings: List["AgentFindingEntity"] = relationship( + "AgentFindingEntity", + back_populates="code_review", + cascade="all, delete-orphan", + lazy="dynamic", + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + def calculate_quality_score(self) -> int: + """ + Calcula el quality score basado en los findings. + + Formula: score = max(0, 100 - sum(penalties)) + """ + + total_penalty = sum(f.penalty for f in self.findings) + return max(0, 100 - total_penalty) diff --git a/backend/src/models/enums/severity_enum.py b/backend/src/models/enums/severity_enum.py index e69de29..1447158 100644 --- a/backend/src/models/enums/severity_enum.py +++ b/backend/src/models/enums/severity_enum.py @@ -0,0 +1,22 @@ +""" +Enum para niveles de severidad de hallazgos. +Alineado con PostgreSQL ENUM 'finding_severity'. +""" + +from enum import Enum + + +class SeverityEnum(str, Enum): + """ + Niveles de severidad de un hallazgo en la base de datos. + + CRITICAL: OWASP Top 10, explotable inmediatamente + HIGH: Vulnerabilidades comunes que requieren condiciones específicas + MEDIUM: Code smells de seguridad/rendimiento + LOW: Violaciones de estilo menores + """ + + CRITICAL = "CRITICAL" + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" diff --git a/backend/src/models/enums/user_role.py b/backend/src/models/enums/user_role.py new file mode 100644 index 0000000..99751c3 --- /dev/null +++ b/backend/src/models/enums/user_role.py @@ -0,0 +1,18 @@ +""" +Enum para roles de usuario. +Alineado con PostgreSQL ENUM 'user_role'. +""" + +from enum import Enum + + +class UserRole(str, Enum): + """ + Roles de usuario en el sistema. + + DEVELOPER: Acceso básico, límite de 10 análisis/día + ADMIN: Acceso completo, sin límites, puede configurar agentes + """ + + DEVELOPER = "DEVELOPER" + ADMIN = "ADMIN" diff --git a/backend/src/models/finding.py b/backend/src/models/finding.py index e69de29..5043348 100644 --- a/backend/src/models/finding.py +++ b/backend/src/models/finding.py @@ -0,0 +1,106 @@ +""" +Entidad ORM para hallazgos de agentes. +Alineado con tabla 'agent_findings' en PostgreSQL (Supabase). +""" + +import uuid +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict + +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID +from sqlalchemy.orm import relationship + +from src.models.base import Base +from src.models.enums.severity_enum import SeverityEnum + +if TYPE_CHECKING: + from src.models.code_review import CodeReviewEntity + + +class AgentFindingEntity(Base): + """ + Entidad ORM que representa la tabla 'agent_findings' en la base de datos. + + Attributes: + id: UUID del hallazgo + review_id: FK a code_reviews + agent_type: Nombre del agente (SecurityAgent, QualityAgent, etc.) + severity: CRITICAL, HIGH, MEDIUM, LOW + issue_type: Tipo de problema (dangerous_function, sql_injection, etc.) + line_number: Número de línea donde se encontró + code_snippet: Fragmento de código problemático + message: Descripción del problema + suggestion: Sugerencia de corrección + metrics: Métricas adicionales (JSONB) + ai_explanation: Explicación generada por IA - Sprint 3 (JSONB) + mcp_references: Referencias a servidores MCP - Sprint 3 (TEXT[]) + created_at: Timestamp de creación + """ + + __tablename__ = "agent_findings" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + review_id = Column( + UUID(as_uuid=True), + ForeignKey("code_reviews.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + agent_type = Column(String(100), nullable=False, index=True) + severity = Column(Enum(SeverityEnum), nullable=False, index=True) + issue_type = Column(String(200), nullable=False) + line_number = Column(Integer, nullable=False) + code_snippet = Column(Text, nullable=True) + message = Column(Text, nullable=False) + suggestion = Column(Text, nullable=True) + + # Campos adicionales + metrics = Column(JSONB, nullable=True) + + # Sprint 3: IA y MCP + ai_explanation = Column(JSONB, nullable=True) + mcp_references = Column(ARRAY(Text), nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + code_review: "CodeReviewEntity" = relationship("CodeReviewEntity", back_populates="findings") + + def __repr__(self) -> str: + return ( + f"" + ) + + @property + def penalty(self) -> int: + """Retorna la penalización para el quality score según severidad.""" + penalties = { + SeverityEnum.CRITICAL: 10, + SeverityEnum.HIGH: 5, + SeverityEnum.MEDIUM: 2, + SeverityEnum.LOW: 1, + } + return penalties.get(self.severity, 0) + + def to_dict(self) -> Dict[str, Any]: + """Convierte la entidad a diccionario.""" + return { + "id": str(self.id), + "review_id": str(self.review_id), + "agent_type": self.agent_type, + "severity": self.severity.value if self.severity else None, + "issue_type": self.issue_type, + "line_number": self.line_number, + "code_snippet": self.code_snippet, + "message": self.message, + "suggestion": self.suggestion, + "metrics": self.metrics, + "created_at": self.created_at.isoformat() if self.created_at else None, + } diff --git a/backend/src/models/user.py b/backend/src/models/user.py index e69de29..9db0232 100644 --- a/backend/src/models/user.py +++ b/backend/src/models/user.py @@ -0,0 +1,86 @@ +""" +Entidad ORM para usuarios. +Alineado con tabla 'users' en PostgreSQL (Supabase). +""" + +from datetime import date, datetime +from typing import TYPE_CHECKING, List + +from sqlalchemy import Column, Date, DateTime, Enum, Integer, String +from sqlalchemy.orm import relationship + +from src.models.base import Base +from src.models.enums.user_role import UserRole + +if TYPE_CHECKING: + from src.models.code_review import CodeReviewEntity + + +class UserEntity(Base): + """ + Entidad ORM que representa la tabla 'users' en la base de datos. + + Attributes: + id: Clerk user_id (VARCHAR, PK) + email: Email único del usuario + name: Nombre del usuario (opcional) + avatar_url: URL del avatar (opcional) + role: DEVELOPER o ADMIN + daily_analysis_count: Contador de análisis del día + last_analysis_date: Fecha del último análisis + created_at: Timestamp de creación + updated_at: Timestamp de última actualización + """ + + __tablename__ = "users" + + # Clerk user_id como PK (no es UUID, es string de Clerk) + id = Column(String(255), primary_key=True) + email = Column(String(255), unique=True, nullable=False, index=True) + name = Column(String(255), nullable=True) + avatar_url = Column(String(500), nullable=True) + role = Column(Enum(UserRole), default=UserRole.DEVELOPER, nullable=False, index=True) + + # Rate limiting (RN3: 10 análisis/día para developers) + daily_analysis_count = Column(Integer, default=0, nullable=False) + last_analysis_date = Column(Date, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + code_reviews: List["CodeReviewEntity"] = relationship( + "CodeReviewEntity", back_populates="user", cascade="all, delete-orphan", lazy="dynamic" + ) + + def __repr__(self) -> str: + return f"" + + def can_analyze(self, max_daily: int = 10) -> bool: + """ + Verifica si el usuario puede realizar más análisis hoy. + + Args: + max_daily: Límite diario para developers (default: 10) + + Returns: + True si puede analizar, False si alcanzó el límite + """ + if self.role == UserRole.ADMIN: + return True + + today = date.today() + if self.last_analysis_date != today: + return True + + return self.daily_analysis_count < max_daily + + def increment_analysis_count(self) -> None: + """Incrementa el contador de análisis del día.""" + today = date.today() + if self.last_analysis_date != today: + self.daily_analysis_count = 1 + self.last_analysis_date = today + else: + self.daily_analysis_count += 1 diff --git a/backend/src/repositories/user_repo.py b/backend/src/repositories/user_repo.py index e69de29..812f24f 100644 --- a/backend/src/repositories/user_repo.py +++ b/backend/src/repositories/user_repo.py @@ -0,0 +1,147 @@ +""" +Repositorio para operaciones CRUD de usuarios. + +Maneja la persistencia en la tabla 'users' usando UserEntity. +""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy.orm import Session + +from src.models.enums.user_role import UserRole +from src.models.user import UserEntity + + +class UserRepository: + """ + Repositorio para gestionar usuarios en la base de datos. + + Responsabilidad única: operaciones de persistencia sobre la tabla users. + """ + + def __init__(self, db: Session): + """ + Inicializa el repositorio con una sesión de base de datos. + + Args: + db: Sesión de SQLAlchemy. + """ + self._db = db + + def get_by_id(self, user_id: str) -> Optional[UserEntity]: + """ + Busca un usuario por su ID (Clerk user_id). + + Args: + user_id: ID del usuario (Clerk sub). + + Returns: + UserEntity si existe, None si no. + """ + return self._db.query(UserEntity).filter(UserEntity.id == user_id).first() + + def get_by_email(self, email: str) -> Optional[UserEntity]: + """ + Busca un usuario por su email. + + Args: + email: Email del usuario. + + Returns: + UserEntity si existe, None si no. + """ + return self._db.query(UserEntity).filter(UserEntity.email == email).first() + + def create( + self, + user_id: str, + email: str, + name: Optional[str] = None, + avatar_url: Optional[str] = None, + role: UserRole = UserRole.DEVELOPER, + ) -> UserEntity: + """ + Crea un nuevo usuario en la base de datos. + + Args: + user_id: ID del usuario (Clerk sub). + email: Email del usuario. + name: Nombre del usuario (opcional). + avatar_url: URL del avatar (opcional). + role: Rol del usuario (default: DEVELOPER). + + Returns: + UserEntity creado. + """ + now = datetime.utcnow() + user = UserEntity( + id=user_id, + email=email, + name=name, + avatar_url=avatar_url, + role=role, + daily_analysis_count=0, + created_at=now, + updated_at=now, + ) + self._db.add(user) + self._db.commit() + self._db.refresh(user) + return user + + def update( + self, + user: UserEntity, + email: Optional[str] = None, + name: Optional[str] = None, + avatar_url: Optional[str] = None, + ) -> UserEntity: + """ + Actualiza los datos de un usuario existente. + + Args: + user: Entidad de usuario a actualizar. + email: Nuevo email (opcional). + name: Nuevo nombre (opcional). + avatar_url: Nueva URL de avatar (opcional). + + Returns: + UserEntity actualizado. + """ + if email is not None: + user.email = email + if name is not None: + user.name = name + if avatar_url is not None: + user.avatar_url = avatar_url + + user.updated_at = datetime.utcnow() + self._db.commit() + self._db.refresh(user) + return user + + def delete(self, user: UserEntity) -> None: + """ + Elimina un usuario de la base de datos. + + Args: + user: Entidad de usuario a eliminar. + """ + self._db.delete(user) + self._db.commit() + + def increment_analysis_count(self, user: UserEntity) -> UserEntity: + """ + Incrementa el contador de análisis del usuario. + + Args: + user: Entidad de usuario. + + Returns: + UserEntity con contador actualizado. + """ + user.increment_analysis_count() + self._db.commit() + self._db.refresh(user) + return user diff --git a/backend/src/routers/auth.py b/backend/src/routers/auth.py index e69de29..42af8a4 100644 --- a/backend/src/routers/auth.py +++ b/backend/src/routers/auth.py @@ -0,0 +1,117 @@ +""" +Router de autenticación. + +Expone endpoint POST /api/v1/auth/login para sincronizar +usuarios de Clerk con la base de datos (AC Escenario 1). +""" + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from src.core.database import get_db +from src.external.clerk_client import ( + ClerkClient, + ClerkTokenExpiredError, + ClerkTokenInvalidError, +) +from src.repositories.user_repo import UserRepository +from src.schemas.user import User +from src.services.auth_service import AuthService + +router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"]) + +http_bearer = HTTPBearer() + + +@router.post("/login", response_model=User) +async def login( + credentials: HTTPAuthorizationCredentials = Depends(http_bearer), + db: Session = Depends(get_db), +) -> User: + """ + Sincroniza usuario de Clerk con la base de datos. + + Este endpoint cumple con AC Escenario 1 de JIRA: + - Valida el token JWT de Clerk + - Crea el usuario en la BD si no existe + - Actualiza los datos del usuario si ya existe + - Retorna el User schema con los datos sincronizados + + Args: + credentials: Token JWT en header Authorization: Bearer + db: Sesión de base de datos. + + Returns: + User: Usuario sincronizado. + + Raises: + HTTPException 401: Si el token es inválido o expirado. + """ + token = credentials.credentials + + # Inyectar dependencias + clerk_client = ClerkClient() + user_repository = UserRepository(db) + auth_service = AuthService(clerk_client, user_repository) + + try: + user = auth_service.login_user(token) + return user + + except ClerkTokenExpiredError: + raise HTTPException( + status_code=401, + detail="Token expirado", + headers={"WWW-Authenticate": "Bearer"}, + ) + except ClerkTokenInvalidError: + raise HTTPException( + status_code=401, + detail="Token inválido", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +@router.get("/me", response_model=User) +async def get_current_user_info( + credentials: HTTPAuthorizationCredentials = Depends(http_bearer), +) -> User: + """ + Obtiene información del usuario actual sin sincronizar con BD. + + Útil para verificar el estado de autenticación desde el frontend. + + Args: + credentials: Token JWT en header Authorization: Bearer + + Returns: + User: Datos del usuario extraídos del token. + + Raises: + HTTPException 401: Si el token es inválido o expirado. + """ + token = credentials.credentials + clerk_client = ClerkClient() + + try: + payload = clerk_client.verify_token(token) + + return User( + id=payload["user_id"], + email=payload.get("email", ""), + name=payload.get("name"), + ) + + except ClerkTokenExpiredError: + raise HTTPException( + status_code=401, + detail="Token expirado", + headers={"WWW-Authenticate": "Bearer"}, + ) + except ClerkTokenInvalidError: + raise HTTPException( + status_code=401, + detail="Token inválido", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/backend/src/services/auth_service.py b/backend/src/services/auth_service.py index e69de29..8ee0f8f 100644 --- a/backend/src/services/auth_service.py +++ b/backend/src/services/auth_service.py @@ -0,0 +1,119 @@ +""" +Servicio de autenticación. + +Orquesta la validación de tokens JWT de Clerk y la sincronización +de usuarios en la base de datos. +""" + +from src.external.clerk_client import ClerkClient +from src.models.user import UserEntity +from src.repositories.user_repo import UserRepository +from src.schemas.user import Role, User + + +class AuthService: + """ + Servicio para gestionar la autenticación de usuarios. + + Responsabilidad: lógica de negocio para validación de tokens + y sincronización de usuarios con Clerk. + """ + + def __init__(self, clerk_client: ClerkClient, user_repository: UserRepository): + """ + Inicializa el servicio con sus dependencias. + + Args: + clerk_client: Cliente para validar tokens de Clerk. + user_repository: Repositorio para operaciones de usuarios. + """ + self._clerk_client = clerk_client + self._user_repository = user_repository + + def login_user(self, token: str) -> User: + """ + Procesa el login de un usuario con token de Clerk. + + Flujo: + 1. Valida el token JWT con Clerk + 2. Busca el usuario en la BD + 3. Si no existe, lo crea; si existe, actualiza sus datos + 4. Retorna el User schema + + Args: + token: Token JWT de Clerk. + + Returns: + User schema con los datos del usuario. + + Raises: + ClerkTokenError: Si el token es inválido o expirado. + """ + # 1. Validar token con Clerk + clerk_data = self._clerk_client.verify_token(token) + + user_id = clerk_data["user_id"] + email = clerk_data.get("email") + name = clerk_data.get("name") + + # 2. Buscar usuario en BD + user_entity = self._user_repository.get_by_id(user_id) + + # 3. Crear o actualizar usuario + if not user_entity: + user_entity = self._user_repository.create( + user_id=user_id, + email=email, + name=name, + ) + else: + user_entity = self._user_repository.update( + user=user_entity, + email=email, + name=name, + ) + + # 4. Convertir a schema + return self._entity_to_schema(user_entity) + + def get_user_from_token(self, token: str) -> User: + """ + Obtiene un User schema a partir de un token válido. + + No sincroniza con la BD, solo valida el token. + Útil para el middleware de protección de rutas. + + Args: + token: Token JWT de Clerk. + + Returns: + User schema con datos del token. + + Raises: + ClerkTokenError: Si el token es inválido o expirado. + """ + clerk_data = self._clerk_client.verify_token(token) + + return User( + id=clerk_data["user_id"], + email=clerk_data.get("email", ""), + name=clerk_data.get("name"), + role=Role.DEVELOPER, + ) + + def _entity_to_schema(self, entity: UserEntity) -> User: + """ + Convierte UserEntity a User schema. + + Args: + entity: Entidad de usuario. + + Returns: + User schema. + """ + return User( + id=entity.id, + email=entity.email, + name=entity.name, + role=Role(entity.role.value.lower()), + ) diff --git a/backend/tests/generate_jwt.py b/backend/tests/generate_jwt.py new file mode 100644 index 0000000..a334f80 --- /dev/null +++ b/backend/tests/generate_jwt.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta + +from jose import jwt + +# Simula el JWT que Clerk genera con el template "supabase" +token = jwt.encode( + { + # Claims automáticos de Clerk (siempre incluidos) + "sub": "user_36E0_CjDHVmkse", # user.id - Clerk lo agrega automáticamente + "iat": int(datetime.utcnow().timestamp()), + "exp": int( + (datetime.utcnow() + timedelta(seconds=60)).timestamp() + ), # 60s como en tu template + "iss": "https://enabled-cattle-58.clerk.accounts.dev", + # Claims personalizados de tu template + "name": "test backend", + "role": "authenticated", + "email": "testbackend@codeguard.ai", + "app_metadata": {}, + "user_metadata": {}, + }, + "sk_test_hwourB8W6TcFQwgvcmMln6lwFZSUwesWOD8zSWbteZ", + algorithm="HS256", +) + +print("Token JWT:") +print(token) +print("\n--- Para probar con cURL ---") +print( + f'curl -X POST "http://localhost:8000/api/v1/auth/login" -H "Content-Type: application/json" -d \'{{"token": "{token}"}}\'' +) diff --git a/backend/tests/integration/test_auth_router.py b/backend/tests/integration/test_auth_router.py new file mode 100644 index 0000000..c3ef8ad --- /dev/null +++ b/backend/tests/integration/test_auth_router.py @@ -0,0 +1,270 @@ +"""Tests de integración para auth router.""" + +import time +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient +from jose import jwt + +from src.main import app +from src.models.enums.user_role import UserRole +from src.models.user import UserEntity + +# Test secret key +TEST_SECRET_KEY = "test-secret-key-for-router-tests" + + +def create_valid_token(user_id: str = "user_123", email: str = "test@example.com") -> str: + """Genera un token JWT válido para tests.""" + now = int(time.time()) + payload = { + "sub": user_id, + "email": email, + "name": "Test User", + "exp": now + 3600, + "iat": now, + } + return jwt.encode(payload, TEST_SECRET_KEY, algorithm="HS256") + + +def create_expired_token() -> str: + """Genera un token JWT expirado.""" + now = int(time.time()) + payload = { + "sub": "user_expired", + "email": "expired@example.com", + "exp": now - 3600, # Expirado hace 1 hora + "iat": now - 7200, + } + return jwt.encode(payload, TEST_SECRET_KEY, algorithm="HS256") + + +@pytest.fixture +def client(): + """TestClient de FastAPI.""" + return TestClient(app) + + +@pytest.fixture +def mock_user_entity(): + """UserEntity mockeado.""" + entity = MagicMock(spec=UserEntity) + entity.id = "user_123" + entity.email = "test@example.com" + entity.name = "Test User" + entity.role = UserRole.DEVELOPER + return entity + + +class TestLoginEndpoint: + """Tests para POST /api/v1/auth/login.""" + + @patch("src.routers.auth.ClerkClient") + @patch("src.routers.auth.UserRepository") + @patch("src.routers.auth.get_db") + def test_login_success_new_user( + self, mock_get_db, mock_repo_class, mock_clerk_class, client, mock_user_entity + ): + """Login exitoso crea usuario nuevo.""" + # Arrange + mock_clerk = MagicMock() + mock_clerk.verify_token.return_value = { + "user_id": "user_123", + "email": "test@example.com", + "name": "Test User", + } + mock_clerk_class.return_value = mock_clerk + + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = None # Usuario no existe + mock_repo.create.return_value = mock_user_entity + mock_repo_class.return_value = mock_repo + + mock_session = MagicMock() + mock_get_db.return_value = iter([mock_session]) + + token = create_valid_token() + + # Act + response = client.post( + "/api/v1/auth/login", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Assert + assert response.status_code == 200 + data = response.json() + assert data["id"] == "user_123" + assert data["email"] == "test@example.com" + + @patch("src.routers.auth.ClerkClient") + @patch("src.routers.auth.UserRepository") + @patch("src.routers.auth.get_db") + def test_login_success_existing_user( + self, mock_get_db, mock_repo_class, mock_clerk_class, client, mock_user_entity + ): + """Login exitoso actualiza usuario existente.""" + # Arrange + mock_clerk = MagicMock() + mock_clerk.verify_token.return_value = { + "user_id": "user_123", + "email": "updated@example.com", + "name": "Updated Name", + } + mock_clerk_class.return_value = mock_clerk + + mock_repo = MagicMock() + mock_repo.get_by_id.return_value = mock_user_entity # Usuario existe + mock_repo.update.return_value = mock_user_entity + mock_repo_class.return_value = mock_repo + + mock_session = MagicMock() + mock_get_db.return_value = iter([mock_session]) + + token = create_valid_token() + + # Act + response = client.post( + "/api/v1/auth/login", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Assert + assert response.status_code == 200 + + @patch("src.routers.auth.ClerkClient") + @patch("src.routers.auth.get_db") + def test_login_token_expired(self, mock_get_db, mock_clerk_class, client): + """Token expirado retorna 401.""" + # Arrange + from src.external.clerk_client import ClerkTokenExpiredError + + mock_clerk = MagicMock() + mock_clerk.verify_token.side_effect = ClerkTokenExpiredError("Token expirado") + mock_clerk_class.return_value = mock_clerk + + mock_session = MagicMock() + mock_get_db.return_value = iter([mock_session]) + + token = create_expired_token() + + # Act + response = client.post( + "/api/v1/auth/login", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Assert + assert response.status_code == 401 + assert "expirado" in response.json()["detail"].lower() + + @patch("src.routers.auth.ClerkClient") + @patch("src.routers.auth.get_db") + def test_login_token_invalid(self, mock_get_db, mock_clerk_class, client): + """Token inválido retorna 401.""" + # Arrange + from src.external.clerk_client import ClerkTokenInvalidError + + mock_clerk = MagicMock() + mock_clerk.verify_token.side_effect = ClerkTokenInvalidError("Token inválido") + mock_clerk_class.return_value = mock_clerk + + mock_session = MagicMock() + mock_get_db.return_value = iter([mock_session]) + + # Act + response = client.post( + "/api/v1/auth/login", + headers={"Authorization": f"Bearer invalid-token"}, + ) + + # Assert + assert response.status_code == 401 + assert "inválido" in response.json()["detail"].lower() + + def test_login_missing_token(self, client): + """Sin token retorna 401 o 403 (depende de versión FastAPI).""" + response = client.post("/api/v1/auth/login") + + # 401 en versiones nuevas de Starlette, 403 en anteriores + assert response.status_code in (401, 403) + + +class TestGetMeEndpoint: + """Tests para GET /api/v1/auth/me.""" + + @patch("src.routers.auth.ClerkClient") + def test_get_me_success(self, mock_clerk_class, client): + """Token válido retorna datos del usuario.""" + # Arrange + mock_clerk = MagicMock() + mock_clerk.verify_token.return_value = { + "user_id": "user_me", + "email": "me@example.com", + "name": "Current User", + } + mock_clerk_class.return_value = mock_clerk + + token = create_valid_token(user_id="user_me", email="me@example.com") + + # Act + response = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Assert + assert response.status_code == 200 + data = response.json() + assert data["id"] == "user_me" + assert data["email"] == "me@example.com" + + @patch("src.routers.auth.ClerkClient") + def test_get_me_token_expired(self, mock_clerk_class, client): + """Token expirado retorna 401.""" + # Arrange + from src.external.clerk_client import ClerkTokenExpiredError + + mock_clerk = MagicMock() + mock_clerk.verify_token.side_effect = ClerkTokenExpiredError("Token expirado") + mock_clerk_class.return_value = mock_clerk + + token = create_expired_token() + + # Act + response = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Assert + assert response.status_code == 401 + assert "expirado" in response.json()["detail"].lower() + + @patch("src.routers.auth.ClerkClient") + def test_get_me_token_invalid(self, mock_clerk_class, client): + """Token inválido retorna 401.""" + # Arrange + from src.external.clerk_client import ClerkTokenInvalidError + + mock_clerk = MagicMock() + mock_clerk.verify_token.side_effect = ClerkTokenInvalidError("Token inválido") + mock_clerk_class.return_value = mock_clerk + + # Act + response = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer bad-token"}, + ) + + # Assert + assert response.status_code == 401 + assert "inválido" in response.json()["detail"].lower() + + def test_get_me_missing_token(self, client): + """Sin token retorna 401 o 403 (depende de versión FastAPI).""" + response = client.get("/api/v1/auth/me") + + # 401 en versiones nuevas de Starlette, 403 en anteriores + assert response.status_code in (401, 403) diff --git a/backend/tests/unit/dependencies/test_get_db.py b/backend/tests/unit/dependencies/test_get_db.py new file mode 100644 index 0000000..f8eabe1 --- /dev/null +++ b/backend/tests/unit/dependencies/test_get_db.py @@ -0,0 +1,95 @@ +"""Tests para get_db dependency.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +class TestGetDb: + """Tests para get_db dependency.""" + + @patch("src.core.dependencies.get_db.SessionLocal") + def test_get_db_yields_session(self, mock_session_local): + """get_db yields una sesión de base de datos.""" + from src.core.dependencies.get_db import get_db + + mock_session = MagicMock() + mock_session_local.return_value = mock_session + + # Act + generator = get_db() + session = next(generator) + + # Assert + assert session == mock_session + mock_session_local.assert_called_once() + + @patch("src.core.dependencies.get_db.SessionLocal") + def test_get_db_closes_session_after_use(self, mock_session_local): + """get_db cierra la sesión después de usarla.""" + from src.core.dependencies.get_db import get_db + + mock_session = MagicMock() + mock_session_local.return_value = mock_session + + # Act + generator = get_db() + session = next(generator) + + # Simular fin del request + try: + next(generator) + except StopIteration: + pass + + # Assert + mock_session.close.assert_called_once() + + @patch("src.core.dependencies.get_db.SessionLocal") + def test_get_db_closes_session_on_exception(self, mock_session_local): + """get_db cierra la sesión incluso si hay excepción.""" + from src.core.dependencies.get_db import get_db + + mock_session = MagicMock() + mock_session_local.return_value = mock_session + + # Act + generator = get_db() + session = next(generator) + + # Simular excepción y cierre + try: + generator.throw(Exception("Test exception")) + except Exception: + pass + + # Assert + mock_session.close.assert_called_once() + + @patch("src.core.dependencies.get_db.SessionLocal") + def test_get_db_can_be_used_as_context(self, mock_session_local): + """get_db funciona correctamente en contexto de FastAPI Depends.""" + from src.core.dependencies.get_db import get_db + + mock_session = MagicMock() + mock_session_local.return_value = mock_session + + # Simular uso típico con Depends + db_generator = get_db() + + # Obtener sesión + db = next(db_generator) + assert db is mock_session + + # Usar la sesión + db.query.return_value = "result" + result = db.query() + assert result == "result" + + # Cerrar (simular fin de request) + try: + next(db_generator) + except StopIteration: + pass + + mock_session.close.assert_called_once() diff --git a/backend/tests/unit/external/__init__.py b/backend/tests/unit/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/external/test_clerk_client.py b/backend/tests/unit/external/test_clerk_client.py new file mode 100644 index 0000000..b184ced --- /dev/null +++ b/backend/tests/unit/external/test_clerk_client.py @@ -0,0 +1,115 @@ +"""Tests para ClerkClient.""" + +import time +from unittest.mock import MagicMock, patch + +import pytest +from jose import jwt + +from src.external.clerk_client import ( + ClerkClient, + ClerkTokenExpiredError, + ClerkTokenInvalidError, +) + +# Constante para el secret key de tests +TEST_SECRET_KEY = "test-secret-key-12345" + + +def create_valid_token() -> str: + """Genera un token JWT válido.""" + now = int(time.time()) + payload = { + "sub": "user_test123", + "email": "test@example.com", + "name": "Test User", + "exp": now + 3600, + "iat": now, + } + return jwt.encode(payload, TEST_SECRET_KEY, algorithm="HS256") + + +def create_expired_token() -> str: + """Genera un token JWT expirado.""" + now = int(time.time()) + payload = { + "sub": "user_expired", + "email": "expired@example.com", + "exp": now - 3600, + "iat": now - 7200, + } + return jwt.encode(payload, TEST_SECRET_KEY, algorithm="HS256") + + +class TestClerkClient: + """Tests para ClerkClient.""" + + @patch("src.external.clerk_client.settings") + def test_verify_token_valid(self, mock_settings: MagicMock): + """Token válido retorna payload correcto.""" + mock_settings.CLERK_SECRET_KEY = TEST_SECRET_KEY + client = ClerkClient() + token = create_valid_token() + + result = client.verify_token(token) + + assert result["user_id"] == "user_test123" + assert result["email"] == "test@example.com" + assert result["name"] == "Test User" + + @patch("src.external.clerk_client.settings") + def test_verify_token_expired(self, mock_settings: MagicMock): + """Token expirado lanza ClerkTokenExpiredError.""" + mock_settings.CLERK_SECRET_KEY = TEST_SECRET_KEY + client = ClerkClient() + token = create_expired_token() + + with pytest.raises(ClerkTokenExpiredError): + client.verify_token(token) + + @patch("src.external.clerk_client.settings") + def test_verify_token_invalid(self, mock_settings: MagicMock): + """Token inválido lanza ClerkTokenInvalidError.""" + mock_settings.CLERK_SECRET_KEY = TEST_SECRET_KEY + client = ClerkClient() + + with pytest.raises(ClerkTokenInvalidError): + client.verify_token("invalid-token-string") + + @patch("src.external.clerk_client.settings") + def test_verify_token_malformed(self, mock_settings: MagicMock): + """Token malformado lanza ClerkTokenInvalidError.""" + mock_settings.CLERK_SECRET_KEY = TEST_SECRET_KEY + client = ClerkClient() + + with pytest.raises(ClerkTokenInvalidError): + client.verify_token("not.a.valid.jwt.token") + + @patch("src.external.clerk_client.settings") + def test_get_user_id_from_token(self, mock_settings: MagicMock): + """get_user_id_from_token retorna el user_id.""" + mock_settings.CLERK_SECRET_KEY = TEST_SECRET_KEY + client = ClerkClient() + token = create_valid_token() + + user_id = client.get_user_id_from_token(token) + + assert user_id == "user_test123" + + @patch("src.external.clerk_client.settings") + def test_get_user_id_missing_sub(self, mock_settings: MagicMock): + """Token sin sub lanza ClerkTokenInvalidError.""" + mock_settings.CLERK_SECRET_KEY = TEST_SECRET_KEY + client = ClerkClient() + + now = int(time.time()) + payload = { + "email": "nosub@example.com", + "exp": now + 3600, + } + token = jwt.encode(payload, TEST_SECRET_KEY, algorithm="HS256") + + with pytest.raises(ClerkTokenInvalidError) as exc: + client.get_user_id_from_token(token) + + assert "user_id" in str(exc.value).lower() diff --git a/backend/tests/unit/middleware/test_auth.py b/backend/tests/unit/middleware/test_auth.py index e75240f..d8b2366 100644 --- a/backend/tests/unit/middleware/test_auth.py +++ b/backend/tests/unit/middleware/test_auth.py @@ -1,12 +1,13 @@ """Tests para la dependencia de autenticación.""" -import os -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials -from src.core.dependencies.auth import get_current_user +from src.core.dependencies.auth import get_current_user, get_optional_user +from src.external.clerk_client import ClerkTokenExpiredError, ClerkTokenInvalidError from src.schemas.user import Role, User @@ -14,37 +15,107 @@ class TestGetCurrentUser: """Tests para get_current_user.""" @pytest.mark.asyncio - @patch.dict(os.environ, {"ENVIRONMENT": "production"}) - async def test_production_requires_valid_token(self): - """En producción, un token inválido debe lanzar 401.""" + async def test_missing_credentials_raises_401(self): + """Sin credenciales debe lanzar 401.""" with pytest.raises(HTTPException) as exc: - await get_current_user(token="invalid-token") + await get_current_user(credentials=None) assert exc.value.status_code == 401 + assert "requerido" in exc.value.detail.lower() @pytest.mark.asyncio - @patch.dict(os.environ, {"ENVIRONMENT": "production"}) - async def test_production_missing_token_raises_401(self): - """En producción, sin token debe lanzar 401.""" - with pytest.raises(HTTPException) as exc: - await get_current_user(token="") + async def test_valid_token_returns_user(self): + """Token válido retorna usuario.""" + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="valid-token") - assert exc.value.status_code == 401 + mock_payload = { + "user_id": "user_abc123", + "email": "test@example.com", + "name": "Test User", + } - @pytest.mark.asyncio - @patch.dict(os.environ, {"ENVIRONMENT": "development"}) - async def test_development_returns_mock_user(self): - """En desarrollo, retorna usuario mock.""" - user = await get_current_user(token="any-token") + with patch("src.core.dependencies.auth.ClerkClient") as MockClerk: + mock_client = MockClerk.return_value + mock_client.verify_token.return_value = mock_payload + + user = await get_current_user(credentials=credentials) assert isinstance(user, User) - assert user.id == "user_123" + assert user.id == "user_abc123" + assert user.email == "test@example.com" + assert user.name == "Test User" assert user.role == Role.DEVELOPER @pytest.mark.asyncio - @patch.dict(os.environ, {"ENVIRONMENT": "development"}) - async def test_development_accepts_empty_token(self): - """En desarrollo, acepta token vacío.""" - user = await get_current_user(token="") + async def test_expired_token_raises_401(self): + """Token expirado debe lanzar 401.""" + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="expired-token") - assert isinstance(user, User) + with patch("src.core.dependencies.auth.ClerkClient") as MockClerk: + mock_client = MockClerk.return_value + mock_client.verify_token.side_effect = ClerkTokenExpiredError("Token expirado") + + with pytest.raises(HTTPException) as exc: + await get_current_user(credentials=credentials) + + assert exc.value.status_code == 401 + assert "expirado" in exc.value.detail.lower() + + @pytest.mark.asyncio + async def test_invalid_token_raises_401(self): + """Token inválido debe lanzar 401.""" + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="invalid-token") + + with patch("src.core.dependencies.auth.ClerkClient") as MockClerk: + mock_client = MockClerk.return_value + mock_client.verify_token.side_effect = ClerkTokenInvalidError("Token inválido") + + with pytest.raises(HTTPException) as exc: + await get_current_user(credentials=credentials) + + assert exc.value.status_code == 401 + assert "inválido" in exc.value.detail.lower() + + +class TestGetOptionalUser: + """Tests para get_optional_user.""" + + @pytest.mark.asyncio + async def test_no_credentials_returns_none(self): + """Sin credenciales retorna None.""" + result = await get_optional_user(credentials=None) + assert result is None + + @pytest.mark.asyncio + async def test_valid_credentials_returns_user(self): + """Con credenciales válidas retorna usuario.""" + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="valid-token") + + mock_payload = { + "user_id": "user_optional", + "email": "optional@test.com", + "name": "Optional User", + } + + with patch("src.core.dependencies.auth.ClerkClient") as MockClerk: + mock_client = MockClerk.return_value + mock_client.verify_token.return_value = mock_payload + + user = await get_optional_user(credentials=credentials) + + assert user is not None + assert user.id == "user_optional" + + @pytest.mark.asyncio + async def test_invalid_token_raises_401(self): + """Token inválido en get_optional_user debe lanzar 401.""" + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="bad-token") + + with patch("src.core.dependencies.auth.ClerkClient") as MockClerk: + mock_client = MockClerk.return_value + mock_client.verify_token.side_effect = ClerkTokenInvalidError("Token inválido") + + with pytest.raises(HTTPException) as exc: + await get_optional_user(credentials=credentials) + + assert exc.value.status_code == 401 diff --git a/backend/tests/unit/models/test_code_review.py b/backend/tests/unit/models/test_code_review.py index e69de29..4f7d707 100644 --- a/backend/tests/unit/models/test_code_review.py +++ b/backend/tests/unit/models/test_code_review.py @@ -0,0 +1,97 @@ +"""Tests para CodeReviewEntity model.""" + +import uuid +from datetime import datetime +from unittest.mock import MagicMock, PropertyMock + +import pytest + +from src.models.code_review import CodeReviewEntity +from src.models.enums.review_status import ReviewStatus +from src.models.enums.severity_enum import SeverityEnum + + +class TestCodeReviewEntityRepr: + """Tests para __repr__.""" + + def test_repr_returns_readable_string(self): + """__repr__ retorna representación legible.""" + review_id = uuid.uuid4() + review = CodeReviewEntity( + id=review_id, + user_id="user_123", + filename="test_file.py", + code_content=b"encrypted_content", + status=ReviewStatus.COMPLETED, + ) + + result = repr(review) + + assert "CodeReviewEntity" in result + assert "test_file.py" in result + assert "COMPLETED" in result or "completed" in result.lower() + + +class TestCodeReviewEntityCalculateQualityScore: + """Tests para calculate_quality_score.""" + + def test_calculate_quality_score_no_findings(self): + """Sin findings retorna score 100.""" + review = CodeReviewEntity( + id=uuid.uuid4(), + user_id="user_123", + filename="clean_file.py", + code_content=b"content", + ) + # Mock de findings vacío + review.findings = [] + + score = review.calculate_quality_score() + + assert score == 100 + + def test_calculate_quality_score_with_findings(self): + """Con findings calcula penalidades correctamente.""" + review = CodeReviewEntity( + id=uuid.uuid4(), + user_id="user_123", + filename="file_with_issues.py", + code_content=b"content", + ) + + # Mock de findings con penalidades + finding1 = MagicMock() + finding1.penalty = 10 # CRITICAL + finding2 = MagicMock() + finding2.penalty = 5 # HIGH + finding3 = MagicMock() + finding3.penalty = 2 # MEDIUM + + review.findings = [finding1, finding2, finding3] + + score = review.calculate_quality_score() + + # 100 - (10 + 5 + 2) = 83 + assert score == 83 + + def test_calculate_quality_score_floor_at_zero(self): + """Score mínimo es 0, no negativo.""" + review = CodeReviewEntity( + id=uuid.uuid4(), + user_id="user_123", + filename="terrible_file.py", + code_content=b"content", + ) + + # Mock de muchos findings críticos + findings = [] + for _ in range(15): + f = MagicMock() + f.penalty = 10 # 15 x 10 = 150 + findings.append(f) + + review.findings = findings + + score = review.calculate_quality_score() + + assert score == 0 # max(0, 100 - 150) = 0 diff --git a/backend/tests/unit/models/test_finding.py b/backend/tests/unit/models/test_finding.py index e69de29..e4859b4 100644 --- a/backend/tests/unit/models/test_finding.py +++ b/backend/tests/unit/models/test_finding.py @@ -0,0 +1,170 @@ +"""Tests para AgentFindingEntity model.""" + +import uuid +from datetime import datetime + +import pytest + +from src.models.enums.severity_enum import SeverityEnum +from src.models.finding import AgentFindingEntity + + +class TestAgentFindingEntityRepr: + """Tests para __repr__.""" + + def test_repr_returns_readable_string(self): + """__repr__ retorna representación legible.""" + finding_id = uuid.uuid4() + review_id = uuid.uuid4() + finding = AgentFindingEntity( + id=finding_id, + review_id=review_id, + agent_type="SecurityAgent", + severity=SeverityEnum.HIGH, + issue_type="sql_injection", + line_number=42, + message="SQL injection detected", + ) + + result = repr(finding) + + assert "AgentFindingEntity" in result + assert "SecurityAgent" in result + assert "42" in result + + +class TestAgentFindingEntityPenalty: + """Tests para penalty property.""" + + def test_penalty_critical(self): + """CRITICAL tiene penalidad de 10.""" + finding = AgentFindingEntity( + id=uuid.uuid4(), + review_id=uuid.uuid4(), + agent_type="SecurityAgent", + severity=SeverityEnum.CRITICAL, + issue_type="dangerous_function", + line_number=1, + message="Critical issue", + ) + + assert finding.penalty == 10 + + def test_penalty_high(self): + """HIGH tiene penalidad de 5.""" + finding = AgentFindingEntity( + id=uuid.uuid4(), + review_id=uuid.uuid4(), + agent_type="SecurityAgent", + severity=SeverityEnum.HIGH, + issue_type="hardcoded_password", + line_number=10, + message="High severity issue", + ) + + assert finding.penalty == 5 + + def test_penalty_medium(self): + """MEDIUM tiene penalidad de 2.""" + finding = AgentFindingEntity( + id=uuid.uuid4(), + review_id=uuid.uuid4(), + agent_type="QualityAgent", + severity=SeverityEnum.MEDIUM, + issue_type="code_smell", + line_number=20, + message="Medium severity issue", + ) + + assert finding.penalty == 2 + + def test_penalty_low(self): + """LOW tiene penalidad de 1.""" + finding = AgentFindingEntity( + id=uuid.uuid4(), + review_id=uuid.uuid4(), + agent_type="StyleAgent", + severity=SeverityEnum.LOW, + issue_type="style_violation", + line_number=30, + message="Low severity issue", + ) + + assert finding.penalty == 1 + + +class TestAgentFindingEntityToDict: + """Tests para to_dict.""" + + def test_to_dict_complete(self): + """to_dict retorna diccionario con todos los campos.""" + finding_id = uuid.uuid4() + review_id = uuid.uuid4() + created = datetime(2025, 12, 1, 10, 0, 0) + + finding = AgentFindingEntity( + id=finding_id, + review_id=review_id, + agent_type="SecurityAgent", + severity=SeverityEnum.HIGH, + issue_type="sql_injection", + line_number=42, + code_snippet="cursor.execute(f'SELECT * FROM users WHERE id={user_id}')", + message="Potential SQL injection vulnerability", + suggestion="Use parameterized queries instead", + metrics={"confidence": 0.95}, + created_at=created, + ) + + result = finding.to_dict() + + assert result["id"] == str(finding_id) + assert result["review_id"] == str(review_id) + assert result["agent_type"] == "SecurityAgent" + assert result["severity"] == "HIGH" + assert result["issue_type"] == "sql_injection" + assert result["line_number"] == 42 + assert "SELECT * FROM users" in result["code_snippet"] + assert result["message"] == "Potential SQL injection vulnerability" + assert result["suggestion"] == "Use parameterized queries instead" + assert result["metrics"] == {"confidence": 0.95} + assert result["created_at"] == "2025-12-01T10:00:00" + + def test_to_dict_with_none_values(self): + """to_dict maneja valores None correctamente.""" + finding = AgentFindingEntity( + id=uuid.uuid4(), + review_id=uuid.uuid4(), + agent_type="SecurityAgent", + severity=SeverityEnum.LOW, + issue_type="minor_issue", + line_number=1, + message="Minor issue found", + code_snippet=None, + suggestion=None, + metrics=None, + created_at=None, + ) + + result = finding.to_dict() + + assert result["code_snippet"] is None + assert result["suggestion"] is None + assert result["metrics"] is None + assert result["created_at"] is None + + def test_to_dict_severity_none(self): + """to_dict maneja severity None.""" + finding = AgentFindingEntity( + id=uuid.uuid4(), + review_id=uuid.uuid4(), + agent_type="TestAgent", + severity=None, + issue_type="test", + line_number=1, + message="Test message", + ) + + result = finding.to_dict() + + assert result["severity"] is None diff --git a/backend/tests/unit/models/test_user.py b/backend/tests/unit/models/test_user.py new file mode 100644 index 0000000..053c17f --- /dev/null +++ b/backend/tests/unit/models/test_user.py @@ -0,0 +1,169 @@ +"""Tests para UserEntity model.""" + +from datetime import date, datetime +from unittest.mock import patch + +import pytest + +from src.models.enums.user_role import UserRole +from src.models.user import UserEntity + + +class TestUserEntityRepr: + """Tests para __repr__.""" + + def test_repr_returns_readable_string(self): + """__repr__ retorna representación legible.""" + user = UserEntity( + id="user_123", + email="test@example.com", + role=UserRole.DEVELOPER, + ) + + result = repr(user) + + assert "user_123" in result + assert "test@example.com" in result + assert "UserEntity" in result + + +class TestUserEntityCanAnalyze: + """Tests para can_analyze (rate limiting RN3).""" + + def test_admin_always_can_analyze(self): + """Admin siempre puede analizar sin límite.""" + user = UserEntity( + id="admin_user", + email="admin@example.com", + role=UserRole.ADMIN, + daily_analysis_count=100, # Muchos análisis + last_analysis_date=date.today(), + ) + + assert user.can_analyze() is True + assert user.can_analyze(max_daily=5) is True + + def test_developer_can_analyze_new_day(self): + """Developer puede analizar si es un nuevo día.""" + yesterday = date(2025, 11, 30) + user = UserEntity( + id="dev_user", + email="dev@example.com", + role=UserRole.DEVELOPER, + daily_analysis_count=10, # Alcanzó límite ayer + last_analysis_date=yesterday, + ) + + with patch("src.models.user.date") as mock_date: + mock_date.today.return_value = date(2025, 12, 1) # Hoy es nuevo día + assert user.can_analyze() is True + + def test_developer_can_analyze_under_limit(self): + """Developer puede analizar si está bajo el límite diario.""" + today = date.today() + user = UserEntity( + id="dev_user", + email="dev@example.com", + role=UserRole.DEVELOPER, + daily_analysis_count=5, + last_analysis_date=today, + ) + + assert user.can_analyze(max_daily=10) is True + + def test_developer_cannot_analyze_at_limit(self): + """Developer NO puede analizar si alcanzó el límite.""" + today = date.today() + user = UserEntity( + id="dev_user", + email="dev@example.com", + role=UserRole.DEVELOPER, + daily_analysis_count=10, + last_analysis_date=today, + ) + + assert user.can_analyze(max_daily=10) is False + + def test_developer_cannot_analyze_over_limit(self): + """Developer NO puede analizar si está sobre el límite.""" + today = date.today() + user = UserEntity( + id="dev_user", + email="dev@example.com", + role=UserRole.DEVELOPER, + daily_analysis_count=15, + last_analysis_date=today, + ) + + assert user.can_analyze(max_daily=10) is False + + def test_can_analyze_with_no_previous_analysis(self): + """Usuario sin análisis previos puede analizar.""" + user = UserEntity( + id="new_user", + email="new@example.com", + role=UserRole.DEVELOPER, + daily_analysis_count=0, + last_analysis_date=None, + ) + + assert user.can_analyze() is True + + +class TestUserEntityIncrementAnalysisCount: + """Tests para increment_analysis_count.""" + + def test_increment_resets_on_new_day(self): + """Contador se reinicia en nuevo día.""" + yesterday = date(2025, 11, 30) + user = UserEntity( + id="dev_user", + email="dev@example.com", + role=UserRole.DEVELOPER, + daily_analysis_count=5, + last_analysis_date=yesterday, + ) + + with patch("src.models.user.date") as mock_date: + today = date(2025, 12, 1) + mock_date.today.return_value = today + + user.increment_analysis_count() + + assert user.daily_analysis_count == 1 + assert user.last_analysis_date == today + + def test_increment_same_day(self): + """Contador se incrementa en el mismo día.""" + today = date.today() + user = UserEntity( + id="dev_user", + email="dev@example.com", + role=UserRole.DEVELOPER, + daily_analysis_count=3, + last_analysis_date=today, + ) + + user.increment_analysis_count() + + assert user.daily_analysis_count == 4 + assert user.last_analysis_date == today + + def test_increment_first_analysis_ever(self): + """Primer análisis del usuario.""" + user = UserEntity( + id="new_user", + email="new@example.com", + role=UserRole.DEVELOPER, + daily_analysis_count=0, + last_analysis_date=None, + ) + + with patch("src.models.user.date") as mock_date: + today = date(2025, 12, 1) + mock_date.today.return_value = today + + user.increment_analysis_count() + + assert user.daily_analysis_count == 1 + assert user.last_analysis_date == today diff --git a/backend/tests/unit/repositories/test_user_repo.py b/backend/tests/unit/repositories/test_user_repo.py new file mode 100644 index 0000000..7096246 --- /dev/null +++ b/backend/tests/unit/repositories/test_user_repo.py @@ -0,0 +1,336 @@ +"""Tests para UserRepository.""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy.orm import Session + +from src.models.enums.user_role import UserRole +from src.models.user import UserEntity +from src.repositories.user_repo import UserRepository + + +class TestUserRepository: + """Tests para UserRepository.""" + + @pytest.fixture + def mock_session(self): + """Mock de SQLAlchemy Session.""" + return MagicMock(spec=Session) + + @pytest.fixture + def repo(self, mock_session): + """Instancia de UserRepository con session mockeada.""" + return UserRepository(mock_session) + + @pytest.fixture + def sample_user_entity(self): + """Crea una entidad de usuario de prueba.""" + entity = MagicMock(spec=UserEntity) + entity.id = "user_123" + entity.email = "test@example.com" + entity.name = "Test User" + entity.role = UserRole.DEVELOPER + entity.daily_analysis_count = 0 + entity.last_analysis_date = None + entity.created_at = datetime(2025, 1, 1, 12, 0, 0) + entity.updated_at = datetime(2025, 1, 1, 12, 0, 0) + return entity + + +class TestGetById: + """Tests para get_by_id.""" + + @pytest.fixture + def mock_session(self): + """Mock de SQLAlchemy Session.""" + return MagicMock(spec=Session) + + @pytest.fixture + def repo(self, mock_session): + """Instancia de UserRepository.""" + return UserRepository(mock_session) + + @pytest.fixture + def sample_user_entity(self): + """Entidad de usuario de prueba.""" + entity = MagicMock(spec=UserEntity) + entity.id = "user_123" + entity.email = "test@example.com" + entity.name = "Test User" + entity.role = UserRole.DEVELOPER + return entity + + def test_get_by_id_found(self, repo, mock_session, sample_user_entity): + """get_by_id retorna usuario si existe.""" + # Arrange + mock_query = MagicMock() + mock_session.query.return_value = mock_query + mock_query.filter.return_value.first.return_value = sample_user_entity + + # Act + result = repo.get_by_id("user_123") + + # Assert + assert result == sample_user_entity + mock_session.query.assert_called_once_with(UserEntity) + + def test_get_by_id_not_found(self, repo, mock_session): + """get_by_id retorna None si usuario no existe.""" + # Arrange + mock_query = MagicMock() + mock_session.query.return_value = mock_query + mock_query.filter.return_value.first.return_value = None + + # Act + result = repo.get_by_id("nonexistent_user") + + # Assert + assert result is None + + +class TestGetByEmail: + """Tests para get_by_email.""" + + @pytest.fixture + def mock_session(self): + """Mock de SQLAlchemy Session.""" + return MagicMock(spec=Session) + + @pytest.fixture + def repo(self, mock_session): + """Instancia de UserRepository.""" + return UserRepository(mock_session) + + @pytest.fixture + def sample_user_entity(self): + """Entidad de usuario de prueba.""" + entity = MagicMock(spec=UserEntity) + entity.id = "user_456" + entity.email = "found@example.com" + entity.name = "Found User" + entity.role = UserRole.DEVELOPER + return entity + + def test_get_by_email_found(self, repo, mock_session, sample_user_entity): + """get_by_email retorna usuario si existe.""" + # Arrange + mock_query = MagicMock() + mock_session.query.return_value = mock_query + mock_query.filter.return_value.first.return_value = sample_user_entity + + # Act + result = repo.get_by_email("found@example.com") + + # Assert + assert result == sample_user_entity + assert result.email == "found@example.com" + + def test_get_by_email_not_found(self, repo, mock_session): + """get_by_email retorna None si email no existe.""" + # Arrange + mock_query = MagicMock() + mock_session.query.return_value = mock_query + mock_query.filter.return_value.first.return_value = None + + # Act + result = repo.get_by_email("notfound@example.com") + + # Assert + assert result is None + + +class TestCreate: + """Tests para create.""" + + @pytest.fixture + def mock_session(self): + """Mock de SQLAlchemy Session.""" + return MagicMock(spec=Session) + + @pytest.fixture + def repo(self, mock_session): + """Instancia de UserRepository.""" + return UserRepository(mock_session) + + def test_create_user_success(self, repo, mock_session): + """create crea usuario y llama add, commit, refresh.""" + # Act + with patch("src.repositories.user_repo.datetime") as mock_datetime: + mock_datetime.utcnow.return_value = datetime(2025, 12, 1, 10, 0, 0) + result = repo.create( + user_id="new_user_123", + email="newuser@example.com", + name="New User", + avatar_url="https://example.com/avatar.png", + role=UserRole.DEVELOPER, + ) + + # Assert + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + mock_session.refresh.assert_called_once() + + # Verificar que el usuario fue creado con los datos correctos + created_user = mock_session.add.call_args[0][0] + assert created_user.id == "new_user_123" + assert created_user.email == "newuser@example.com" + assert created_user.name == "New User" + assert created_user.avatar_url == "https://example.com/avatar.png" + assert created_user.role == UserRole.DEVELOPER + + def test_create_user_with_defaults(self, repo, mock_session): + """create usa valores por defecto correctamente.""" + # Act + with patch("src.repositories.user_repo.datetime") as mock_datetime: + mock_datetime.utcnow.return_value = datetime(2025, 12, 1, 10, 0, 0) + result = repo.create( + user_id="minimal_user", + email="minimal@example.com", + ) + + # Assert + created_user = mock_session.add.call_args[0][0] + assert created_user.id == "minimal_user" + assert created_user.email == "minimal@example.com" + assert created_user.name is None + assert created_user.avatar_url is None + assert created_user.role == UserRole.DEVELOPER + assert created_user.daily_analysis_count == 0 + + +class TestUpdate: + """Tests para update.""" + + @pytest.fixture + def mock_session(self): + """Mock de SQLAlchemy Session.""" + return MagicMock(spec=Session) + + @pytest.fixture + def repo(self, mock_session): + """Instancia de UserRepository.""" + return UserRepository(mock_session) + + @pytest.fixture + def existing_user(self): + """Usuario existente para actualizar.""" + user = MagicMock(spec=UserEntity) + user.id = "user_to_update" + user.email = "old@example.com" + user.name = "Old Name" + user.avatar_url = "https://old.com/avatar.png" + user.role = UserRole.DEVELOPER + return user + + def test_update_all_fields(self, repo, mock_session, existing_user): + """update actualiza todos los campos proporcionados.""" + # Act + with patch("src.repositories.user_repo.datetime") as mock_datetime: + mock_datetime.utcnow.return_value = datetime(2025, 12, 1, 15, 0, 0) + result = repo.update( + user=existing_user, + email="new@example.com", + name="New Name", + avatar_url="https://new.com/avatar.png", + ) + + # Assert + assert existing_user.email == "new@example.com" + assert existing_user.name == "New Name" + assert existing_user.avatar_url == "https://new.com/avatar.png" + mock_session.commit.assert_called_once() + mock_session.refresh.assert_called_once_with(existing_user) + + def test_update_partial_fields(self, repo, mock_session, existing_user): + """update solo actualiza campos proporcionados.""" + original_email = existing_user.email + original_avatar = existing_user.avatar_url + + # Act + with patch("src.repositories.user_repo.datetime") as mock_datetime: + mock_datetime.utcnow.return_value = datetime(2025, 12, 1, 15, 0, 0) + result = repo.update( + user=existing_user, + name="Only Name Changed", + ) + + # Assert + assert existing_user.name == "Only Name Changed" + # Email y avatar no deberían cambiar + mock_session.commit.assert_called_once() + + def test_update_no_fields(self, repo, mock_session, existing_user): + """update sin campos aún actualiza updated_at y hace commit.""" + # Act + with patch("src.repositories.user_repo.datetime") as mock_datetime: + mock_datetime.utcnow.return_value = datetime(2025, 12, 1, 15, 0, 0) + result = repo.update(user=existing_user) + + # Assert + mock_session.commit.assert_called_once() + mock_session.refresh.assert_called_once() + + +class TestDelete: + """Tests para delete.""" + + @pytest.fixture + def mock_session(self): + """Mock de SQLAlchemy Session.""" + return MagicMock(spec=Session) + + @pytest.fixture + def repo(self, mock_session): + """Instancia de UserRepository.""" + return UserRepository(mock_session) + + @pytest.fixture + def user_to_delete(self): + """Usuario para eliminar.""" + user = MagicMock(spec=UserEntity) + user.id = "user_to_delete" + user.email = "delete@example.com" + return user + + def test_delete_success(self, repo, mock_session, user_to_delete): + """delete elimina usuario y llama commit.""" + # Act + repo.delete(user_to_delete) + + # Assert + mock_session.delete.assert_called_once_with(user_to_delete) + mock_session.commit.assert_called_once() + + +class TestIncrementAnalysisCount: + """Tests para increment_analysis_count.""" + + @pytest.fixture + def mock_session(self): + """Mock de SQLAlchemy Session.""" + return MagicMock(spec=Session) + + @pytest.fixture + def repo(self, mock_session): + """Instancia de UserRepository.""" + return UserRepository(mock_session) + + @pytest.fixture + def user_with_count(self): + """Usuario con contador de análisis.""" + user = MagicMock(spec=UserEntity) + user.id = "counting_user" + user.daily_analysis_count = 5 + return user + + def test_increment_analysis_count_success(self, repo, mock_session, user_with_count): + """increment_analysis_count llama al método del usuario y hace commit.""" + # Act + result = repo.increment_analysis_count(user_with_count) + + # Assert + user_with_count.increment_analysis_count.assert_called_once() + mock_session.commit.assert_called_once() + mock_session.refresh.assert_called_once_with(user_with_count) diff --git a/backend/tests/unit/services/test_auth_service.py b/backend/tests/unit/services/test_auth_service.py index e69de29..ae2e4a4 100644 --- a/backend/tests/unit/services/test_auth_service.py +++ b/backend/tests/unit/services/test_auth_service.py @@ -0,0 +1,100 @@ +"""Tests para AuthService.""" + +from unittest.mock import MagicMock + +import pytest + +from src.external.clerk_client import ClerkTokenInvalidError +from src.models.enums.user_role import UserRole +from src.models.user import UserEntity +from src.schemas.user import Role, User +from src.services.auth_service import AuthService + + +class TestAuthService: + """Tests para AuthService.""" + + @pytest.fixture + def mock_clerk_client(self): + """Mock de ClerkClient.""" + return MagicMock() + + @pytest.fixture + def mock_user_repository(self): + """Mock de UserRepository.""" + return MagicMock() + + @pytest.fixture + def auth_service(self, mock_clerk_client, mock_user_repository): + """Crea instancia de AuthService con mocks.""" + return AuthService(mock_clerk_client, mock_user_repository) + + @pytest.fixture + def sample_user_entity(self): + """Crea una entidad de usuario de prueba.""" + entity = MagicMock(spec=UserEntity) + entity.id = "user_abc123" + entity.email = "test@example.com" + entity.name = "Test User" + entity.role = UserRole.DEVELOPER + return entity + + def test_login_user_creates_new_user( + self, auth_service, mock_clerk_client, mock_user_repository, sample_user_entity + ): + """login_user crea usuario si no existe.""" + mock_clerk_client.verify_token.return_value = { + "user_id": "user_new", + "email": "new@example.com", + "name": "New User", + } + mock_user_repository.get_by_id.return_value = None + mock_user_repository.create.return_value = sample_user_entity + + result = auth_service.login_user("valid-token") + + assert isinstance(result, User) + mock_user_repository.get_by_id.assert_called_once_with("user_new") + mock_user_repository.create.assert_called_once() + + def test_login_user_updates_existing_user( + self, auth_service, mock_clerk_client, mock_user_repository, sample_user_entity + ): + """login_user actualiza usuario si ya existe.""" + mock_clerk_client.verify_token.return_value = { + "user_id": "user_abc123", + "email": "updated@example.com", + "name": "Updated Name", + } + mock_user_repository.get_by_id.return_value = sample_user_entity + mock_user_repository.update.return_value = sample_user_entity + + result = auth_service.login_user("valid-token") + + assert isinstance(result, User) + mock_user_repository.update.assert_called_once() + mock_user_repository.create.assert_not_called() + + def test_login_user_invalid_token_raises( + self, auth_service, mock_clerk_client, mock_user_repository + ): + """login_user propaga error si token es inválido.""" + mock_clerk_client.verify_token.side_effect = ClerkTokenInvalidError("Invalid") + + with pytest.raises(ClerkTokenInvalidError): + auth_service.login_user("invalid-token") + + def test_get_user_from_token(self, auth_service, mock_clerk_client): + """get_user_from_token retorna User sin sincronizar BD.""" + mock_clerk_client.verify_token.return_value = { + "user_id": "user_fromtoken", + "email": "fromtoken@example.com", + "name": "From Token", + } + + result = auth_service.get_user_from_token("valid-token") + + assert isinstance(result, User) + assert result.id == "user_fromtoken" + assert result.email == "fromtoken@example.com" + assert result.role == Role.DEVELOPER