diff --git a/.gitignore b/.gitignore index 122ac54..64f0fd9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ frontend/dist/ # OS .DS_Store +**/.DS_Store Thumbs.db # Agent/Session files @@ -65,4 +66,11 @@ backend/coverage/ # OpenCode/Sisyphus .sisyphus/ +.opencode/ + +# Playwright +playwright-report/ +test-results/ +blob-report/ + diff --git a/backend/api/migrations/0003_cheatsheet_user.py b/backend/api/migrations/0003_cheatsheet_user.py new file mode 100644 index 0000000..ae5726d --- /dev/null +++ b/backend/api/migrations/0003_cheatsheet_user.py @@ -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), + ), + ] diff --git a/backend/api/migrations/0004_cheatsheet_user_nonnull.py b/backend/api/migrations/0004_cheatsheet_user_nonnull.py new file mode 100644 index 0000000..69e8f2d --- /dev/null +++ b/backend/api/migrations/0004_cheatsheet_user_nonnull.py @@ -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), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 500dcf2..42c1e89 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -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) @@ -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") diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 851e178..9da5bdd 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -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.""" diff --git a/backend/api/tests.py b/backend/api/tests.py index c6ecda3..f6003c5 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -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, @@ -73,6 +74,9 @@ 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", @@ -80,6 +84,7 @@ def test_build_full_latex_wraps_content(self): margins="1in", columns=1, font_size="10pt", + user=self.user, ) full = sheet.build_full_latex() assert "\\begin{document}" in full @@ -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 @@ -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 @@ -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, @@ -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, @@ -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, @@ -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() @@ -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): diff --git a/backend/api/views.py b/backend/api/views.py index 498736f..b3c24a1 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -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 @@ -129,6 +129,7 @@ def generate_sheet(request): @api_view(["POST"]) +@permission_classes([IsAuthenticated]) def compile_latex(request): """ POST /api/compile/ @@ -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: @@ -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') + + 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): @@ -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, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5668ef0..0118c44 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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'; @@ -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'); @@ -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(() => { @@ -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}` } : {}), + }, body: JSON.stringify({ title: nextSheet.title, latex_content: nextSheet.content, @@ -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 (
You haven't saved any cheat sheets yet.
+ +
+ Created: {new Date(sheet.created_at).toLocaleDateString()}
+ Last modified: {new Date(sheet.updated_at).toLocaleDateString()}
+