From 9370ba56483ef9eafdf3366f807e3091b91f17dd Mon Sep 17 00:00:00 2001 From: Anurag Singh Date: Fri, 29 May 2026 22:37:31 +0530 Subject: [PATCH 1/2] feat: add file size validation for resume uploads (closes #135) --- backend/app/config.py | 2 + backend/app/models/dsa_question.py | 10 +- backend/app/routers/application.py | 14 ++ backend/tests/conftest.py | 1 + .../tests/test_routers/test_application.py | 183 ++++++++++++++++++ 5 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_routers/test_application.py diff --git a/backend/app/config.py b/backend/app/config.py index dea15df..b59c9c7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,6 +6,8 @@ class Settings(BaseSettings): APP_NAME: str = "InterXAI" DEBUG: bool = False API_V1_PREFIX: str = "/api/v1" + MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 + # Database DATABASE_URL: str = "sqlite+aiosqlite:///./dev.db" diff --git a/backend/app/models/dsa_question.py b/backend/app/models/dsa_question.py index 38071bb..f984c05 100644 --- a/backend/app/models/dsa_question.py +++ b/backend/app/models/dsa_question.py @@ -1,11 +1,14 @@ from typing import Any -from sqlalchemy import Integer, String, Text +from sqlalchemy import Integer, String, Text, JSON from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from app.models.base import BaseTable +# Use JSONB on PostgreSQL, and standard JSON on SQLite/other dialects +JSONB_TYPE = JSON().with_variant(JSONB, "postgresql") + class DsaQuestion(BaseTable): __tablename__ = "dsa_questions" @@ -16,8 +19,9 @@ class DsaQuestion(BaseTable): description: Mapped[str] = mapped_column(Text, nullable=False) # Each entry: {"stdin": str, "expected_stdout": str}. Multi-line values use "\n". - test_cases: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False) - sample_test_cases: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True) + test_cases: Mapped[list[dict[str, Any]]] = mapped_column(JSONB_TYPE, nullable=False) + sample_test_cases: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB_TYPE, nullable=True) + sample_solution: Mapped[str | None] = mapped_column(Text, nullable=True) time_limit_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=5000) diff --git a/backend/app/routers/application.py b/backend/app/routers/application.py index 108faed..eaf840e 100644 --- a/backend/app/routers/application.py +++ b/backend/app/routers/application.py @@ -5,8 +5,10 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database import get_db from app.exceptions.common import BadRequestError, ForbiddenError, NotFoundError + from app.logger import get_logger from app.models.application import Application from app.models.interview import CustomInterview @@ -91,6 +93,18 @@ async def apply_for_interview( if existing_app_result.scalar_one_or_none(): raise BadRequestError("You have already applied for this interview") + # Validate file size before reading into memory + file_size = resume.size + if file_size is None: + await resume.seek(0, 2) + file_size = await resume.tell() + await resume.seek(0) + + if file_size > settings.MAX_UPLOAD_SIZE: + raise BadRequestError( + f"File size exceeds the maximum allowed limit of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)} MB" + ) + file_bytes = await resume.read() time_str = int(datetime.utcnow().timestamp()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d655435..a6880d8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -48,6 +48,7 @@ async def create_tables() -> AsyncGenerator[None, None]: """Create all ORM tables once per test session, drop them afterwards.""" # Import all models so Base.metadata knows about them. import app.models.application # noqa: F401 + import app.models.dsa_question # noqa: F401 import app.models.interaction # noqa: F401 import app.models.interview # noqa: F401 import app.models.organization # noqa: F401 diff --git a/backend/tests/test_routers/test_application.py b/backend/tests/test_routers/test_application.py new file mode 100644 index 0000000..b4d0622 --- /dev/null +++ b/backend/tests/test_routers/test_application.py @@ -0,0 +1,183 @@ +import pytest +import uuid +from datetime import datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, patch +from httpx import AsyncClient +from app.config import settings + +def _unique_name(prefix: str) -> str: + uid = uuid.uuid4().hex[:8] + return f"{prefix}_{uid}" + +@pytest.mark.asyncio +class TestApplicationSubmit: + """Integration tests for POST /applications/{interview_id} resume upload.""" + + async def _setup_users_and_interview(self, client: AsyncClient) -> tuple[dict[str, Any], dict[str, Any], int]: + """Helper to create an organization, a candidate, and a valid interview.""" + org_username = _unique_name("org") + org_email = f"{org_username}@example.com" + + # 1. Sign up organization + org_res = await client.post( + "/organizations/signup", + json={ + "username": org_username, + "password": "SecurePass123!", + "email": org_email + } + ) + assert org_res.status_code == 201 + org_token = org_res.json()["access_token"] + + # 2. Create an interview + now = datetime.utcnow() + interview_res = await client.post( + "/interviews/", + headers={"Authorization": f"Bearer {org_token}"}, + json={ + "description": "Software Engineering role", + "position": "Backend Developer", + "experience": "Entry", + "submission_deadline": (now + timedelta(days=2)).isoformat() + "Z", + "start_time": (now + timedelta(days=3)).isoformat() + "Z", + "end_time": (now + timedelta(days=5)).isoformat() + "Z", + "duration": 45, + "dsa_score": 40, + "dev_score": 60, + "resume_shortlist_score": 0.0, + "ask_questions_on_resume": False, + "questions": [ + {"question": "What is FastAPI?", "expected_answer": "A modern web framework."} + ], + "dsa_topics": [ + {"topic": "Arrays", "difficulty": "easy"} + ] + } + ) + assert interview_res.status_code == 201 + interview_id = interview_res.json()["id"] + + # 3. Sign up candidate + candidate_username = _unique_name("candidate") + candidate_email = f"{candidate_username}@example.com" + cand_res = await client.post( + "/users/signup", + json={ + "username": candidate_username, + "password": "SecurePass123!", + "email": candidate_email + } + ) + assert cand_res.status_code == 201 + candidate_data = cand_res.json() + + return org_res.json(), candidate_data, interview_id + + async def test_apply_success(self, client: AsyncClient) -> None: + """A normal user should be able to upload a valid resume and apply successfully.""" + org_data, candidate_data, interview_id = await self._setup_users_and_interview(client) + token = candidate_data["token"] + + # 100 bytes resume content + resume_content = b"a" * 100 + + # Mock process_resume_task in the background to avoid external tasks execution + with patch("app.routers.application.default_worker_provider") as mock_worker_prov: + mock_worker = mock_worker_prov.return_value + mock_worker.process_resume_task = AsyncMock() + response = await client.post( + f"/applications/{interview_id}", + headers={"Authorization": f"Bearer {token}"}, + files={"resume": ("resume.pdf", resume_content, "application/pdf")} + ) + assert response.status_code == 201 + assert response.json()["status"] == "applied" + mock_worker.process_resume_task.assert_called_once() + + async def test_apply_exceeds_size_limit(self, client: AsyncClient) -> None: + """Uploading a file exceeding the configured MAX_UPLOAD_SIZE limit must return 400 Bad Request.""" + org_data, candidate_data, interview_id = await self._setup_users_and_interview(client) + token = candidate_data["token"] + + # Temporarily mock Settings.MAX_UPLOAD_SIZE to a very small size (10 bytes) for testing + with patch.object(settings, "MAX_UPLOAD_SIZE", 10): + # 50 bytes exceeds the 10 bytes limit + resume_content = b"a" * 50 + response = await client.post( + f"/applications/{interview_id}", + headers={"Authorization": f"Bearer {token}"}, + files={"resume": ("large_resume.pdf", resume_content, "application/pdf")} + ) + assert response.status_code == 400 + assert "File size exceeds the maximum allowed limit" in response.json()["detail"] + + async def test_apply_with_exact_limit(self, client: AsyncClient) -> None: + """Uploading a file with exact size as the configured MAX_UPLOAD_SIZE limit must succeed.""" + org_data, candidate_data, interview_id = await self._setup_users_and_interview(client) + token = candidate_data["token"] + + with patch.object(settings, "MAX_UPLOAD_SIZE", 20): + resume_content = b"a" * 20 + with patch("app.routers.application.default_worker_provider") as mock_worker_prov: + mock_worker = mock_worker_prov.return_value + mock_worker.process_resume_task = AsyncMock() + response = await client.post( + f"/applications/{interview_id}", + headers={"Authorization": f"Bearer {token}"}, + files={"resume": ("limit_resume.pdf", resume_content, "application/pdf")} + ) + assert response.status_code == 201 + + async def test_apply_org_fails(self, client: AsyncClient) -> None: + """Organizations must be rejected when trying to apply for interviews (403 Forbidden).""" + org_data, candidate_data, interview_id = await self._setup_users_and_interview(client) + org_token = org_data["access_token"] + + resume_content = b"a" * 100 + response = await client.post( + f"/applications/{interview_id}", + headers={"Authorization": f"Bearer {org_token}"}, + files={"resume": ("resume.pdf", resume_content, "application/pdf")} + ) + assert response.status_code == 403 + assert "Organizations cannot apply for interviews" in response.json()["detail"] + + async def test_apply_not_found(self, client: AsyncClient) -> None: + """Applying for a non-existent interview must return 404 Not Found.""" + org_data, candidate_data, _ = await self._setup_users_and_interview(client) + token = candidate_data["token"] + + resume_content = b"a" * 100 + response = await client.post( + "/applications/999999", + headers={"Authorization": f"Bearer {token}"}, + files={"resume": ("resume.pdf", resume_content, "application/pdf")} + ) + assert response.status_code == 404 + assert "Interview not found" in response.json()["detail"] + + async def test_apply_duplicate_fails(self, client: AsyncClient) -> None: + """Applying for the same interview twice must be rejected with 400 Bad Request.""" + org_data, candidate_data, interview_id = await self._setup_users_and_interview(client) + token = candidate_data["token"] + + resume_content = b"a" * 100 + with patch("app.routers.application.default_worker_provider") as mock_worker_prov: + mock_worker = mock_worker_prov.return_value + mock_worker.process_resume_task = AsyncMock() + response1 = await client.post( + f"/applications/{interview_id}", + headers={"Authorization": f"Bearer {token}"}, + files={"resume": ("resume.pdf", resume_content, "application/pdf")} + ) + assert response1.status_code == 201 + + response2 = await client.post( + f"/applications/{interview_id}", + headers={"Authorization": f"Bearer {token}"}, + files={"resume": ("resume.pdf", resume_content, "application/pdf")} + ) + assert response2.status_code == 400 + assert "You have already applied for this interview" in response2.json()["detail"] From d59996053feadad51372b6dae9d6919fd04cee36 Mon Sep 17 00:00:00 2001 From: Anurag Singh Date: Sun, 31 May 2026 15:54:54 +0530 Subject: [PATCH 2/2] Add RabbitMQ broker support and update dependencies --- backend/app/background/taskiq/taskiq.py | 20 ++++++++++---------- backend/app/config.py | 3 +++ backend/pyproject.toml | 2 ++ backend/setup.cfg | 9 +++++++++ backend/setup.py | 4 ++++ 5 files changed, 28 insertions(+), 10 deletions(-) create mode 100644 backend/setup.cfg create mode 100644 backend/setup.py diff --git a/backend/app/background/taskiq/taskiq.py b/backend/app/background/taskiq/taskiq.py index 372234f..6734ddd 100644 --- a/backend/app/background/taskiq/taskiq.py +++ b/backend/app/background/taskiq/taskiq.py @@ -4,14 +4,14 @@ from app.config import settings -if settings.REDIS_URL.startswith("rediss://"): - _ssl_ctx: ssl.SSLContext | None = ssl.create_default_context() - assert _ssl_ctx is not None - _ssl_ctx.check_hostname = False - _ssl_ctx.verify_mode = ssl.CERT_NONE +# Determine broker type +if getattr(settings, "BROKER_TYPE", "redis") == "rabbitmq": + from taskiq_aio_pika import AioPikaBroker, AioPikaResultBackend + broker = AioPikaBroker(url=settings.BROKER_URL).with_result_backend( + AioPikaResultBackend(url=settings.BROKER_URL) + ) else: - _ssl_ctx = None - -broker = ListQueueBroker(url=settings.REDIS_URL).with_result_backend( - RedisAsyncResultBackend(redis_url=settings.REDIS_URL) -) + from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend + broker = ListQueueBroker(url=settings.REDIS_URL).with_result_backend( + RedisAsyncResultBackend(redis_url=settings.REDIS_URL) + ) diff --git a/backend/app/config.py b/backend/app/config.py index b59c9c7..be279bb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,6 +18,9 @@ class Settings(BaseSettings): # Redis/Celery REDIS_URL: str = "redis://localhost:6379/0" + # RabbitMQ support + BROKER_TYPE: str = "redis" # options: redis, rabbitmq + BROKER_URL: str = "redis://localhost:6379/0" # LLM LLM_MODEL_NAME: str = "groq/openai/gpt-oss-120b" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 369060f..a02687f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "pypdf2>=3.0.1", "taskiq>=0.11.0", "taskiq-redis>=0.5.0", + "taskiq-aio-pika>=0.4", + "aio-pika>=9.2", "python-multipart>=0.0.27", "supabase>=2.30.0", "authlib>=1.7.2", diff --git a/backend/setup.cfg b/backend/setup.cfg new file mode 100644 index 0000000..76e9bfd --- /dev/null +++ b/backend/setup.cfg @@ -0,0 +1,9 @@ +[options] +packages = find: +include_package_data = true + +[options.packages.find] +where = . +exclude = + tests* + docs* diff --git a/backend/setup.py b/backend/setup.py new file mode 100644 index 0000000..7f1a176 --- /dev/null +++ b/backend/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + +if __name__ == "__main__": + setup()