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
32 changes: 32 additions & 0 deletions backend/api/compiler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import re

from django.conf import settings


MAX_CHEAT_SHEET_ID = 9_223_372_036_854_775_807
CANONICAL_POSITIVE_INTEGER = re.compile(r"[1-9][0-9]*\Z")


def validate_cheat_sheet_id(value):
if type(value) is int:
cheat_sheet_id = value
elif type(value) is str and CANONICAL_POSITIVE_INTEGER.fullmatch(value):
cheat_sheet_id = int(value)
else:
return None

if not 0 < cheat_sheet_id <= MAX_CHEAT_SHEET_ID:
return None
return cheat_sheet_id


def validate_source_text(content):
if not isinstance(content, str):
return "LaTeX content must be a string"
try:
source_size = len(content.encode("utf-8"))
except UnicodeEncodeError:
return "LaTeX content must be valid UTF-8"
if source_size > settings.COMPILER_SOURCE_MAX_BYTES:
return "LaTeX content exceeds the maximum allowed size"
return None
14 changes: 8 additions & 6 deletions backend/api/latex_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import subprocess
import tempfile

from django.conf import settings

LATEX_HEADER = r"""\documentclass[fleqn]{article}
\usepackage[margin=0.15in]{geometry}
\usepackage{amsmath, amssymb}
Expand Down Expand Up @@ -330,18 +332,18 @@ def compile_latex_to_pdf(content):
subprocess.run(
["tectonic", tex_file_path],
cwd=tempdir,
capture_output=True,
text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
timeout=settings.COMPILER_TIMEOUT_SECONDS,
)
except subprocess.CalledProcessError:
# Propagate the error; the temporary directory will still be cleaned up
raise
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
raise RuntimeError("Failed to compile LaTeX") from None

pdf_file_path = os.path.join(tempdir, "document.pdf")
if not os.path.exists(pdf_file_path):
raise FileNotFoundError("PDF not generated")

# Read and return the PDF bytes before the temporary directory is removed
with open(pdf_file_path, "rb") as pdf_file:
return pdf_file.read()
return pdf_file.read()
18 changes: 18 additions & 0 deletions backend/api/migrations/0009_template_selected_formulas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-08-25

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("api", "0008_cheatsheet_orientation"),
]

operations = [
migrations.AddField(
model_name="template",
name="selected_formulas",
field=models.JSONField(blank=True, default=list),
),
]
1 change: 1 addition & 0 deletions backend/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Template(models.Model):
latex_content = models.TextField()
default_columns = models.IntegerField(default=2)
default_margins = models.CharField(max_length=20, default="0.5in")
selected_formulas = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

Expand Down
24 changes: 10 additions & 14 deletions backend/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,23 @@ class Meta:
"latex_content",
"default_margins",
"default_columns",
"selected_formulas",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]


class PracticeProblemSerializer(serializers.ModelSerializer):
def get_fields(self):
fields = super().get_fields()
request = self.context.get("request")
if request and request.user.is_authenticated:
fields["cheat_sheet"].queryset = CheatSheet.objects.filter(user=request.user)
else:
fields["cheat_sheet"].queryset = CheatSheet.objects.none()
return fields

class Meta:
model = PracticeProblem
fields = [
Expand Down Expand Up @@ -109,17 +119,3 @@ def validate(self, attrs):
else:
attrs["content_source"] = "manual"
return attrs


class CompileRequestSerializer(serializers.Serializer):
"""Accepts either raw content OR a cheat_sheet id to compile."""

content = serializers.CharField(required=False, default="")
cheat_sheet_id = serializers.IntegerField(required=False, default=None)

def validate(self, data):
if not data.get("content") and not data.get("cheat_sheet_id"):
raise serializers.ValidationError(
"Provide either 'content' or 'cheat_sheet_id'."
)
return data
176 changes: 170 additions & 6 deletions backend/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
Run with: pytest (from the backend/ directory)
"""

import subprocess

import pytest
from unittest.mock import patch
from urllib.error import HTTPError
from io import BytesIO
from django.contrib.auth.models import User
from django.test import TestCase
from rest_framework.test import APIClient
from api.latex_utils import LATEX_HEADER, build_dynamic_header, build_latex_for_formulas, normalize_latex_layout
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient, APIRequestFactory
from api.latex_utils import LATEX_HEADER, build_dynamic_header, build_latex_for_formulas, compile_latex_to_pdf, normalize_latex_layout
from api.models import Template, CheatSheet, PracticeProblem
from api.views import YOUTUBE_RESOURCE_CACHE, fetch_top_youtube_video, get_youtube_http_error_message
from api.compiler import validate_cheat_sheet_id
from api.views import CompileAnonThrottle, CompileUserThrottle, YOUTUBE_RESOURCE_CACHE, fetch_top_youtube_video, get_youtube_http_error_message


@pytest.fixture
Expand Down Expand Up @@ -518,7 +522,7 @@ def test_filter_templates_by_subject(self, auth_client, sample_template):
data = resp.json()
assert len(data) >= 1

def test_create_template(self, auth_client):
def test_nonstaff_cannot_create_template(self, auth_client):
resp = auth_client.post(
"/api/templates/",
{
Expand All @@ -528,7 +532,7 @@ def test_create_template(self, auth_client):
},
format="json",
)
assert resp.status_code == 201
assert resp.status_code in (401, 403)


@pytest.mark.django_db
Expand Down Expand Up @@ -822,6 +826,32 @@ def test_create_from_template(self, auth_client, sample_template):
assert data["template"] == sample_template.id
assert data["columns"] == sample_template.default_columns

def test_create_from_template_marks_copied_content_as_generated(self, auth_client):
formulas = [{"class": "ALGEBRA I", "name": "Slope Formula"}]
template = Template.objects.create(
name="Generated template",
subject="algebra",
latex_content="Template content",
default_columns=3,
default_margins="0.5in",
selected_formulas=formulas,
)

response = auth_client.post(
"/api/cheatsheets/from-template/",
{"template_id": template.id, "title": "Generated copy"},
format="json",
)

assert response.status_code == 201
assert response.json()["content_source"] == "generated"
sheet = CheatSheet.objects.get(pk=response.json()["id"])
assert sheet.content_source == "generated"
assert sheet.user == auth_client.handler._force_user
assert sheet.selected_formulas == formulas
assert sheet.columns == template.default_columns
assert sheet.margins == template.default_margins

def test_create_from_template_missing_id(self, auth_client):
resp = auth_client.post(
"/api/cheatsheets/from-template/",
Expand Down Expand Up @@ -1497,3 +1527,137 @@ def test_token_refresh_invalid_token(self, api_client):
format="json",
)
assert resp.status_code == 401


@pytest.mark.django_db
class TestPhaseOneTransfer:
def test_signed_64_bit_sheet_id_boundary_is_canonical(self):
maximum = 9_223_372_036_854_775_807
assert validate_cheat_sheet_id(maximum) == maximum
assert validate_cheat_sheet_id(maximum + 1) is None

def test_templates_are_public_but_writes_require_staff(self, api_client, sample_template):
assert api_client.get("/api/templates/").status_code == 200
assert api_client.post(
"/api/templates/", {"name": "Nope", "subject": "math", "latex_content": "x"}, format="json"
).status_code in (401, 403)
staff = User.objects.create_user(username="staff", password="testpass123", is_staff=True)
api_client.force_authenticate(user=staff)
assert api_client.post(
"/api/templates/", {"name": "Allowed", "subject": "math", "latex_content": "x"}, format="json"
).status_code == 201

def test_template_selection_is_exposed_and_copied(self, api_client, auth_client):
formulas = [{"class": "ALGEBRA I", "name": "Slope Formula"}]
template = Template.objects.create(name="Formula template", subject="algebra", latex_content="Content", selected_formulas=formulas)
assert api_client.get(f"/api/templates/{template.id}/").json()["selected_formulas"] == formulas
response = auth_client.post("/api/cheatsheets/from-template/", {"template_id": template.id}, format="json")
assert response.status_code == 201
assert response.json()["selected_formulas"] == formulas

def test_problems_are_scoped_to_the_authenticated_sheet_owner(self, auth_client, sample_problem):
other = User.objects.create_user(username="other-problem-user", password="testpass123")
client = APIClient()
client.force_authenticate(user=other)
assert client.get("/api/problems/").json() == []
assert client.get(f"/api/problems/{sample_problem.id}/").status_code == 404
assert client.post("/api/problems/", {"cheat_sheet": sample_problem.cheat_sheet_id, "question_latex": "No"}, format="json").status_code == 400

@pytest.mark.parametrize("cheat_sheet_id", ["0", "-1", "01", "1.0", "invalid"])
def test_problem_sheet_filter_rejects_noncanonical_ids(self, auth_client, cheat_sheet_id):
response = auth_client.get(f"/api/problems/?cheat_sheet={cheat_sheet_id}")
assert response.status_code == 400

def test_problem_sheet_filter_keeps_owner_scope(self, auth_client, sample_problem):
response = auth_client.get(f"/api/problems/?cheat_sheet={sample_problem.cheat_sheet_id}")
assert response.status_code == 200
assert [problem["id"] for problem in response.json()] == [sample_problem.id]

@pytest.mark.parametrize("cheat_sheet_id", [True, 1.0, 0, -1, "01", "1.0", 9_223_372_036_854_775_808])
def test_compile_rejects_noncanonical_sheet_ids(self, auth_client, cheat_sheet_id):
assert auth_client.post("/api/compile/", {"cheat_sheet_id": cheat_sheet_id}, format="json").status_code == 400

@override_settings(COMPILER_SOURCE_MAX_BYTES=4)
def test_compile_enforces_utf8_source_byte_limit(self, api_client):
assert api_client.post("/api/compile/", {"content": "1234", "normalize_only": True}, format="json").status_code == 200
assert api_client.post("/api/compile/", {"content": "éé", "normalize_only": True}, format="json").status_code == 200
assert api_client.post("/api/compile/", {"content": "ééé", "normalize_only": True}, format="json").status_code == 413

def test_compile_rejects_json_lone_surrogate(self, api_client):
response = api_client.post(
"/api/compile/",
b'{"content":"\\ud800","normalize_only":true}',
content_type="application/json",
)
assert response.status_code == 400

@patch("api.views.subprocess.run")
@override_settings(COMPILER_TIMEOUT_SECONDS=7)
def test_compile_timeout_and_failure_are_generic(self, run, api_client):
run.side_effect = subprocess.TimeoutExpired("tectonic", 7)
assert api_client.post("/api/compile/", {"content": "x"}, format="json").status_code == 408
assert run.call_args.kwargs["timeout"] == 7
run.side_effect = subprocess.CalledProcessError(1, "tectonic", stderr="sensitive compiler path")
response = api_client.post("/api/compile/", {"content": "x"}, format="json")
assert response.json() == {"error": "Failed to compile LaTeX"}
assert run.call_args.kwargs["stdout"] is subprocess.DEVNULL
assert run.call_args.kwargs["stderr"] is subprocess.DEVNULL

@patch("api.views.subprocess.run", side_effect=FileNotFoundError("sensitive executable path"))
def test_compile_missing_executable_is_generic(self, _run, api_client):
response = api_client.post("/api/compile/", {"content": "x"}, format="json")
assert response.status_code == 500
assert response.json() == {"error": "Failed to compile LaTeX"}

@patch("api.latex_utils.subprocess.run")
def test_compile_helper_discards_compiler_diagnostics(self, run):
run.side_effect = subprocess.CalledProcessError(1, "tectonic", stderr="sensitive compiler path")
with pytest.raises(RuntimeError, match="Failed to compile LaTeX"):
compile_latex_to_pdf("content")
assert run.call_args.kwargs["stdout"] is subprocess.DEVNULL
assert run.call_args.kwargs["stderr"] is subprocess.DEVNULL

@patch("api.latex_utils.subprocess.run", side_effect=FileNotFoundError("sensitive executable path"))
def test_compile_helper_missing_executable_is_generic(self, _run):
with pytest.raises(RuntimeError, match="Failed to compile LaTeX"):
compile_latex_to_pdf("content")

def test_compile_throttles_use_configured_rates(self):
cache.clear()
anonymous = APIRequestFactory().post("/api/compile/", {"content": "x"}, format="json", REMOTE_ADDR="198.51.100.1")
anonymous.user = type("Anonymous", (), {"is_authenticated": False})()
with override_settings(COMPILER_ANON_RATE="1/hour"):
throttle = CompileAnonThrottle()
throttle.rate = throttle.get_rate()
throttle.num_requests, throttle.duration = throttle.parse_rate(throttle.rate)
assert throttle.allow_request(anonymous, None)
assert not throttle.allow_request(anonymous, None)
cache.clear()
authenticated = APIRequestFactory().post("/api/compile/", {"content": "x"}, format="json")
authenticated.user = User(id=999, username="rate-limited")
with override_settings(COMPILER_USER_RATE="1/hour"):
throttle = CompileUserThrottle()
throttle.rate = throttle.get_rate()
throttle.num_requests, throttle.duration = throttle.parse_rate(throttle.rate)
assert throttle.allow_request(authenticated, None)
assert not throttle.allow_request(authenticated, None)

def test_compile_endpoint_enforces_anonymous_throttle(self, api_client):
cache.clear()
with override_settings(COMPILER_ANON_RATE="1/hour"):
assert api_client.post("/api/compile/", {"content": "x", "normalize_only": True}, format="json").status_code == 200
assert api_client.post("/api/compile/", {"content": "x", "normalize_only": True}, format="json").status_code == 429

def test_compile_endpoint_enforces_authenticated_throttle(self, auth_client):
cache.clear()
with override_settings(COMPILER_USER_RATE="1/hour"):
assert auth_client.post("/api/compile/", {"content": "x", "normalize_only": True}, format="json").status_code == 200
assert auth_client.post("/api/compile/", {"content": "x", "normalize_only": True}, format="json").status_code == 429

def test_compile_endpoint_registers_both_throttle_classes(self):
from api.views import compile_latex

assert compile_latex.cls.throttle_classes == [CompileAnonThrottle, CompileUserThrottle]

def test_anonymous_sheet_compile_requires_authentication(self, api_client, sample_sheet):
assert api_client.post("/api/compile/", {"cheat_sheet_id": sample_sheet.id}, format="json").status_code == 401
Loading
Loading