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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ frontend/dist/

# OS
.DS_Store
**/.DS_Store
Thumbs.db

# Agent/Session files
Expand Down Expand Up @@ -65,4 +66,11 @@ backend/coverage/

# OpenCode/Sisyphus
.sisyphus/
.opencode/

# Playwright
playwright-report/
test-results/
blob-report/


21 changes: 21 additions & 0 deletions backend/api/migrations/0003_cheatsheet_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 6.0.2 on 2026-04-21 19:29

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0002_cheatsheet_selected_formulas'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddField(
model_name='cheatsheet',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cheat_sheets', to=settings.AUTH_USER_MODEL),
),
]
21 changes: 21 additions & 0 deletions backend/api/migrations/0004_cheatsheet_user_nonnull.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 6.0.4 on 2026-04-22 17:58

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0003_cheatsheet_user'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AlterField(
model_name='cheatsheet',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cheat_sheets', to=settings.AUTH_USER_MODEL),
),
]
3 changes: 2 additions & 1 deletion backend/api/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.conf import settings
from django.db import models


class Template(models.Model):
name = models.CharField(max_length=200)
subject = models.CharField(max_length=100)
Expand All @@ -21,6 +21,7 @@ class CheatSheet(models.Model):
template = models.ForeignKey(
Template, on_delete=models.SET_NULL, null=True, blank=True
)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="cheat_sheets")
columns = models.IntegerField(default=2)
margins = models.CharField(max_length=20, default="0.5in")
font_size = models.CharField(max_length=10, default="10pt")
Expand Down
3 changes: 2 additions & 1 deletion backend/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,11 @@ class Meta:
"selected_formulas",
"problems",
"full_latex",
"user",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at", "full_latex"]
read_only_fields = ["id", "user", "created_at", "updated_at", "full_latex"]

def get_full_latex(self, obj):
"""Return the fully-assembled LaTeX document string."""
Expand Down
73 changes: 72 additions & 1 deletion backend/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ def sample_template(db):


@pytest.fixture
def sample_sheet(db, sample_template):
def sample_sheet(db, sample_template, auth_client):
return CheatSheet.objects.create(
title="My Test Sheet",
template=sample_template,
user=auth_client.handler._force_user, # the user force_authenticate() set
latex_content="Some content here",
margins="0.75in",
columns=2,
Expand Down Expand Up @@ -73,13 +74,17 @@ def test_str_representation(self):


class TestCheatSheetModel(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="modeluser", password="pass123")

def test_build_full_latex_wraps_content(self):
sheet = CheatSheet.objects.create(
title="Test",
latex_content="Hello World",
margins="1in",
columns=1,
font_size="10pt",
user=self.user,
)
full = sheet.build_full_latex()
assert "\\begin{document}" in full
Expand All @@ -92,6 +97,7 @@ def test_build_full_latex_multicolumn(self):
title="Multi-col",
latex_content="Col content",
columns=3,
user=self.user,
)
full = sheet.build_full_latex()
assert "\\usepackage{multicol}" in full
Expand All @@ -102,6 +108,7 @@ def test_build_full_latex_passthrough(self):
sheet = CheatSheet.objects.create(
title="Raw",
latex_content=raw,
user=self.user,
)
assert sheet.build_full_latex() == raw

Expand All @@ -110,6 +117,7 @@ def test_build_full_latex_passthrough_inserts_problems_before_document_end(self)
sheet = CheatSheet.objects.create(
title="Raw With Problems",
latex_content=raw,
user=self.user,
)
PracticeProblem.objects.create(
cheat_sheet=sheet,
Expand Down Expand Up @@ -137,6 +145,7 @@ def test_build_full_latex_passthrough_inserts_problems_before_end_multicols(self
sheet = CheatSheet.objects.create(
title="Raw Multi",
latex_content=raw,
user=self.user,
)
PracticeProblem.objects.create(
cheat_sheet=sheet,
Expand All @@ -153,6 +162,7 @@ def test_build_full_latex_with_problems(self):
sheet = CheatSheet.objects.create(
title="With Problems",
latex_content="Content",
user=self.user,
)
PracticeProblem.objects.create(
cheat_sheet=sheet,
Expand All @@ -170,6 +180,7 @@ def test_build_full_latex_8pt_uses_extarticle(self):
title="Small Font",
latex_content="Content",
font_size="8pt",
user=self.user,
)

full = sheet.build_full_latex()
Expand Down Expand Up @@ -339,6 +350,66 @@ def test_delete_cheatsheet(self, auth_client, sample_sheet):
assert resp.status_code == 204


@pytest.mark.django_db
class TestCheatSheetAccessControl:
"""Ensure users cannot access or modify another user's cheat sheets."""

@pytest.fixture
def other_user(self, db):
return User.objects.create_user(username="otheruser", password="otherpass123")

@pytest.fixture
def other_client(self, other_user):
client = APIClient()
client.force_authenticate(user=other_user)
return client

def test_list_does_not_return_other_users_sheets(
self, auth_client, other_client, sample_sheet
):
"""User B should not see User A's sheets in list response."""
resp = other_client.get("/api/cheatsheets/")
assert resp.status_code == 200
ids = [s["id"] for s in resp.json()]
assert sample_sheet.id not in ids

def test_retrieve_other_users_sheet_returns_404(
self, other_client, sample_sheet
):
"""User B should get 404 when retrieving User A's sheet by ID."""
resp = other_client.get(f"/api/cheatsheets/{sample_sheet.id}/")
assert resp.status_code == 404

def test_update_other_users_sheet_returns_404(
self, other_client, sample_sheet
):
"""User B should get 404 when updating User A's sheet."""
resp = other_client.patch(
f"/api/cheatsheets/{sample_sheet.id}/",
{"title": "Hacked"},
format="json",
)
assert resp.status_code == 404

def test_delete_other_users_sheet_returns_404(
self, other_client, sample_sheet
):
"""User B should get 404 when deleting User A's sheet."""
resp = other_client.delete(f"/api/cheatsheets/{sample_sheet.id}/")
assert resp.status_code == 404

def test_compile_other_users_sheet_returns_404(
self, other_client, sample_sheet
):
"""User B should get 404 when compiling User A's sheet via cheat_sheet_id."""
resp = other_client.post(
"/api/compile/",
{"cheat_sheet_id": sample_sheet.id},
format="json",
)
assert resp.status_code == 404


@pytest.mark.django_db
class TestCreateFromTemplate:
def test_create_from_template(self, auth_client, sample_template):
Expand Down
15 changes: 12 additions & 3 deletions backend/api/views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from rest_framework.decorators import api_view, action
from rest_framework.decorators import api_view, action, permission_classes
from rest_framework.response import Response
from rest_framework import status, viewsets
from django.http import FileResponse
from django.contrib.auth.models import User
from rest_framework.generics import CreateAPIView
from rest_framework.permissions import AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
from django.shortcuts import get_object_or_404
import subprocess
import tempfile
Expand Down Expand Up @@ -129,6 +129,7 @@ def generate_sheet(request):


@api_view(["POST"])
@permission_classes([IsAuthenticated])
def compile_latex(request):
Comment on lines 131 to 133
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While adding IsAuthenticated here is good, compile_latex still resolves cheat_sheet_id later via get_object_or_404(CheatSheet, pk=cheat_sheet_id) without checking that the sheet belongs to request.user. That creates an IDOR risk (authenticated users can compile other users' sheets if they guess IDs). When a cheat_sheet_id is provided, ensure the lookup is restricted to user=request.user (or enforce object-level permissions) and return 404/403 for non-owned sheets.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in commit 1462605. compile_latex now uses get_object_or_404(CheatSheet, pk=cheat_sheet_id, user=request.user), returning 404 for any sheet not owned by the requesting user.

"""
POST /api/compile/
Expand All @@ -142,7 +143,7 @@ def compile_latex(request):

# If cheat_sheet_id is provided, get content from the cheat sheet
if cheat_sheet_id:
cheatsheet = get_object_or_404(CheatSheet, pk=cheat_sheet_id)
cheatsheet = get_object_or_404(CheatSheet, pk=cheat_sheet_id, user=request.user)
content = cheatsheet.build_full_latex()

if not content:
Expand Down Expand Up @@ -209,6 +210,13 @@ class CheatSheetViewSet(viewsets.ModelViewSet):
"""
queryset = CheatSheet.objects.all()
serializer_class = CheatSheetSerializer
permission_classes = [IsAuthenticated]

def get_queryset(self):
return self.queryset.filter(user=self.request.user).order_by('-updated_at')
Comment on lines +213 to +216
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds per-user isolation for cheat sheets (IsAuthenticated + get_queryset() filtering by request.user), but there don't appear to be tests asserting that a user cannot list/retrieve/update/delete another user's sheets. Adding a multi-user test case would help prevent regressions in access control behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in commit 1462605. Added TestCheatSheetAccessControl with five test cases asserting that list, retrieve, update, delete, and compile all return 404 when a second user attempts to access the first user's sheet.


def perform_create(self, serializer):
serializer.save(user=self.request.user)

@action(detail=False, methods=['post'], url_path='from-template')
def from_template(self, request):
Expand All @@ -226,6 +234,7 @@ def from_template(self, request):

cheatsheet = CheatSheet.objects.create(
title=title,
user=request.user,
template=template,
latex_content=template.latex_content,
margins=template.default_margins,
Expand Down
33 changes: 31 additions & 2 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Routes, Route, Link, Navigate } from 'react-router-dom';
import AuthContext from './context/AuthContext';
import Login from './components/Login';
import SignUp from './components/SignUp';
import Dashboard from './components/Dashboard';
import './App.css'
import CreateCheatSheet from './components/CreateCheatSheet';

Expand Down Expand Up @@ -48,7 +49,7 @@ function App() {
localStorage.setItem('theme', theme);
}, [theme]);

const { user, logoutUser } = useContext(AuthContext);
const { user, authTokens, logoutUser } = useContext(AuthContext);

const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
Expand All @@ -57,6 +58,8 @@ function App() {
const handleReset = () => {
setCheatSheet(DEFAULT_SHEET);
localStorage.setItem('currentCheatSheet', JSON.stringify(DEFAULT_SHEET));
localStorage.removeItem('cheatSheetData');
localStorage.removeItem('cheatSheetLatex');
};

useEffect(() => {
Expand Down Expand Up @@ -90,7 +93,10 @@ function App() {
const sheetId = nextSheet.id;
const response = await fetch(sheetId ? `/api/cheatsheets/${sheetId}/` : '/api/cheatsheets/', {
method: sheetId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(authTokens?.access ? { 'Authorization': `Bearer ${authTokens.access}` } : {}),
},
Comment on lines 94 to +99
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleSave always includes an Authorization header even when authTokens is falsy, which can result in sending Bearer undefined and returning 401s that are hard to diagnose. Consider conditionally adding the header only when a token exists (and/or blocking save attempts when not authenticated).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in commit 1462605. The Authorization header in handleSave is now conditionally spread: ...(authTokens?.access ? { 'Authorization': \****** } : {})`.

body: JSON.stringify({
title: nextSheet.title,
latex_content: nextSheet.content,
Expand Down Expand Up @@ -128,6 +134,22 @@ function App() {
}
};

const handleEditSheet = (sheet) => {
const editSheet = {
id: sheet.id,
title: sheet.title,
content: sheet.latex_content,
columns: sheet.columns,
margins: sheet.margins,
fontSize: sheet.font_size,
selectedFormulas: sheet.selected_formulas || [],
};
setCheatSheet(editSheet);
localStorage.setItem('currentCheatSheet', JSON.stringify(editSheet));
localStorage.removeItem('cheatSheetData');
localStorage.removeItem('cheatSheetLatex');
};

return (
<div className="App">
<header className="app-header">
Expand All @@ -142,6 +164,8 @@ function App() {
{user ? (
<>
<span style={{ fontWeight: 'bold' }}>Hi, {user.username || 'User'}</span>
<Link to="/" className="btn primary" onClick={handleReset}>Create New Sheet</Link>
<Link to="/dashboard" className="btn">My Sheets</Link>
<button onClick={logoutUser} className="btn">Log Out</button>
</>
) : (
Expand Down Expand Up @@ -169,6 +193,11 @@ function App() {
/>
</PrivateRoute>
} />
<Route path="/dashboard" element={
<PrivateRoute>
<Dashboard onEditSheet={handleEditSheet} onCreateNewSheet={handleReset} />
</PrivateRoute>
} />
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<SignUp />} />
</Routes>
Expand Down
Loading
Loading