diff --git a/backend/testjam/core/config.py b/backend/testjam/core/config.py index efa7d65..7b9c270 100644 --- a/backend/testjam/core/config.py +++ b/backend/testjam/core/config.py @@ -27,6 +27,16 @@ class Settings(BaseSettings): REDIS_URL: str | None = None REALTIME_CHANNEL_PREFIX: str = "testjam:rt" + # Hard cap on rows serialized into a single export (execution results, + # exported test cases). Requests above the cap return 413 with a hint + # to narrow the scope. Tune via the MAX_EXPORT_RESULTS env var. + MAX_EXPORT_RESULTS: int = 5000 + + # Chunk size for streaming exports back to the client. 64 KiB matches + # the default uvicorn write buffer, so the kernel send queue stays warm + # without holding the whole payload in a single Python bytes object. + EXPORT_STREAM_CHUNK_BYTES: int = 64 * 1024 + @property def cors_origins_list(self) -> list[str]: return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()] diff --git a/backend/testjam/routers/executions/exports.py b/backend/testjam/routers/executions/exports.py index 8e657be..170d063 100644 --- a/backend/testjam/routers/executions/exports.py +++ b/backend/testjam/routers/executions/exports.py @@ -1,6 +1,7 @@ """Export endpoints: HTML / XLSX for executions, XLSX for project cases.""" import io from datetime import datetime, timezone +from typing import Iterator from urllib.parse import urlparse import openpyxl @@ -9,6 +10,7 @@ from fastapi import Depends, HTTPException, Request from fastapi.responses import StreamingResponse +from sqlalchemy import func from sqlalchemy.orm import Session from testjam.auth.dependencies import get_current_user, require_project_access @@ -23,6 +25,25 @@ from testjam.services.timezones import format_in_user_zone, user_zone_label +def _enforce_export_cap(row_count: int, *, kind: str) -> None: + cap = settings.MAX_EXPORT_RESULTS + if row_count > cap: + raise HTTPException( + status_code=413, + detail=( + f"Export refused: {row_count} {kind} exceeds the configured " + f"cap of {cap}. Narrow the scope (filter by version, " + f"environment, or suite) and retry." + ), + ) + + +def _stream_bytes(payload: bytes) -> Iterator[bytes]: + chunk = settings.EXPORT_STREAM_CHUNK_BYTES + for start in range(0, len(payload), chunk): + yield payload[start:start + chunk] + + @executions_router.get("/{id}/export/xlsx") def export_execution_xlsx( id: int, @@ -32,6 +53,7 @@ def export_execution_xlsx( ex = load_execution_full(db, id) if not ex: raise HTTPException(status_code=404, detail="Not found") + _enforce_export_cap(len(ex.results), kind="results") wb = openpyxl.Workbook() tz_label = user_zone_label(current) @@ -69,10 +91,9 @@ def export_execution_xlsx( buf = io.BytesIO() wb.save(buf) - buf.seek(0) filename = f"execution_{id}.xlsx" return StreamingResponse( - buf, + _stream_bytes(buf.getvalue()), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @@ -89,6 +110,7 @@ def export_execution_html( execution = load_execution_for_report(db, id) if not execution: raise HTTPException(status_code=404, detail="Not found") + _enforce_export_cap(len(execution.results), kind="results") app_settings = get_app_settings(db) base_url = _resolve_base_url(request, app_settings.site_url) @@ -102,7 +124,7 @@ def export_execution_html( filename = f"execution_{id}_{execution.title.replace(' ', '_')}.html" return StreamingResponse( - io.BytesIO(html.encode("utf-8")), + _stream_bytes(html.encode("utf-8")), media_type="text/html; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @@ -131,6 +153,14 @@ def export_cases_xlsx( from testjam.models.project import Project project = db.get(Project, id) suites = db.query(TestSuite).filter(TestSuite.project_id == id, TestSuite.parent_suite_id == None).all() # noqa: E711 + case_count = ( + db.query(func.count(TestCase.id)) + .join(TestSuite, TestSuite.id == TestCase.suite_id) + .filter(TestSuite.project_id == id) + .scalar() + or 0 + ) + _enforce_export_cap(case_count, kind="test cases") wb = openpyxl.Workbook() ws = wb.active @@ -227,10 +257,9 @@ def export_cases_xlsx( buf = io.BytesIO() wb.save(buf) - buf.seek(0) filename = f"cases_{project.name.replace(' ', '_') if project else id}.xlsx" return StreamingResponse( - buf, + _stream_bytes(buf.getvalue()), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) diff --git a/backend/tests/test_export_size_cap.py b/backend/tests/test_export_size_cap.py new file mode 100644 index 0000000..056fb06 --- /dev/null +++ b/backend/tests/test_export_size_cap.py @@ -0,0 +1,99 @@ +"""`MAX_EXPORT_RESULTS` must short-circuit exports before the workbook or +HTML report is materialized — otherwise a 1000-result execution still +buffers megabytes of inline base64 attachments before the cap fires. + +These tests monkeypatch the cap down to a tiny number so the suite stays +fast, then exercise all three export endpoints. +""" +import io + +import openpyxl +import pytest + +from testjam.core.config import settings +from testjam.models.execution import TestExecution, TestResult +from testjam.models.testcase import TestCase, TestSuite +from tests.conftest import TestingSession + +CAP = 3 + + +@pytest.fixture +def low_cap(monkeypatch): + monkeypatch.setattr(settings, "MAX_EXPORT_RESULTS", CAP) + + +def _seed_execution_with_results(project_id, suite_id, result_count): + with TestingSession() as db: + execution = TestExecution( + project_id=project_id, title="Big run", type="manual", status="completed", + ) + db.add(execution) + db.flush() + for index in range(result_count): + case = TestCase(suite_id=suite_id, name=f"TC-{index}", order=index + 1) + db.add(case) + db.flush() + db.add(TestResult( + execution_id=execution.id, test_case_id=case.id, status="passed", + )) + db.commit() + return execution.id + + +def test_xlsx_export_returns_413_above_cap(auth_client, project_id, suite_id, low_cap): + execution_id = _seed_execution_with_results(project_id, suite_id, CAP + 1) + + response = auth_client.get(f"/api/v1/executions/{execution_id}/export/xlsx") + + assert response.status_code == 413 + assert "exceeds" in response.json()["detail"] + + +def test_xlsx_export_succeeds_at_cap(auth_client, project_id, suite_id, low_cap): + execution_id = _seed_execution_with_results(project_id, suite_id, CAP) + + response = auth_client.get(f"/api/v1/executions/{execution_id}/export/xlsx") + + assert response.status_code == 200 + workbook = openpyxl.load_workbook(io.BytesIO(response.content)) + results_sheet = workbook["Results"] + data_rows = list(results_sheet.iter_rows(min_row=2, values_only=True)) + assert len(data_rows) == CAP + + +def test_html_export_returns_413_above_cap(auth_client, project_id, suite_id, low_cap): + execution_id = _seed_execution_with_results(project_id, suite_id, CAP + 1) + + response = auth_client.get(f"/api/v1/executions/{execution_id}/export/html") + + assert response.status_code == 413 + + +def test_cases_xlsx_export_returns_413_above_cap(auth_client, project_id, suite_id, low_cap): + with TestingSession() as db: + for index in range(CAP + 1): + db.add(TestCase(suite_id=suite_id, name=f"OverCap-{index}", order=index + 1)) + db.commit() + + response = auth_client.get(f"/api/v1/projects/{project_id}/cases/export/xlsx") + + assert response.status_code == 413 + + +def test_xlsx_export_streams_in_chunks(auth_client, project_id, suite_id, low_cap, monkeypatch): + """Even at-cap exports must come back via the chunk generator; without + chunking the response body would be the BytesIO blob in one piece. + """ + monkeypatch.setattr(settings, "EXPORT_STREAM_CHUNK_BYTES", 1024) + execution_id = _seed_execution_with_results(project_id, suite_id, CAP) + + with auth_client.stream( + "GET", f"/api/v1/executions/{execution_id}/export/xlsx", + ) as response: + assert response.status_code == 200 + chunks = list(response.iter_bytes()) + + assert len(chunks) >= 1 + body = b"".join(chunks) + assert body.startswith(b"PK")