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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/testjam/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down
39 changes: 34 additions & 5 deletions backend/testjam/routers/executions/exports.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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}"'},
)
Expand All @@ -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)
Expand All @@ -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}"'},
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"'},
)
99 changes: 99 additions & 0 deletions backend/tests/test_export_size_cap.py
Original file line number Diff line number Diff line change
@@ -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")
Loading