From 87dd1fd1f5d5f173595ead4fd148aa121e44a854 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Wed, 15 Apr 2026 03:33:52 +0530 Subject: [PATCH] feat: implement sealed letter protection in backend --- backend/letters/tests.py | 38 +++++++++++++++++++++++++++++++++++--- backend/letters/views.py | 10 +++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/backend/letters/tests.py b/backend/letters/tests.py index 986cc7f..a50ffc7 100644 --- a/backend/letters/tests.py +++ b/backend/letters/tests.py @@ -97,6 +97,28 @@ class LetterAPITest(APITestCase): self.assertEqual(Letter.objects.get().encrypted_metadata, "enc_meta==") self.assertEqual(Letter.objects.get().encrypted_dek, "enc_dek==") + def test_sealed_letters_cannot_be_updated(self): + """Test API returns 400 when trying to update an already sealed letter.""" + letter = Letter.objects.create( + user=self.user, + type="KEPT", + status="SEALED", + public_id="4281edcc-5459-4ff2-bb5e-669fb44e0757", + encrypted_content="enc_xyz==", + encrypted_metadata="enc_meta==", + encrypted_dek="enc_dek==", + ) + payload = { + "public_id": letter.public_id, + "type": "KEPT", + "encrypted_content": "enc_abc==", + "encrypted_metadata": "enc_meta==", + "encrypted_dek": "enc_dek==", + } + response = self.client.put(self.url + letter.public_id + "/", payload) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data, {"error": "Sealed letters cannot be modified."}) + def test_encrypted_dek_is_required_when_storing_encrypted_content_and_metadata(self): """encrypted_dek is required when encrypted_content and encrypted_metadata are present""" payload = {"type": "KEPT", "encrypted_content": "enc_xyz==", "encrypted_metadata": "enc_meta=="} @@ -131,11 +153,15 @@ class LetterAPITest(APITestCase): self.assertEqual(response.status_code, 201) self.assertEqual(Letter.objects.count(), 1) self.assertEqual(LetterImage.objects.count(), 2) + from django.core.files.storage import default_storage + + self.assertTrue(default_storage.exists("encrypted-images/enc_img1.bin")) + self.assertTrue(default_storage.exists("encrypted-images/enc_img2.bin")) def test_cleanup_images_when_letter_is_updated(self): letter = Letter.objects.create(user=self.user, type="KEPT", status="DRAFT") - LetterImage.objects.create(letter=letter, file_name="old1.bin", file=ContentFile(b"data", name="del.bin")) - LetterImage.objects.create(letter=letter, file_name="old2.bin", file=ContentFile(b"data", name="del.bin")) + LetterImage.objects.create(letter=letter, file_name="old1.bin", file=ContentFile(b"data", name="old1.bin")) + LetterImage.objects.create(letter=letter, file_name="old2.bin", file=ContentFile(b"data", name="old2.bin")) response = self.client.put( f"/api/letters/{letter.public_id}/", @@ -148,8 +174,14 @@ class LetterAPITest(APITestCase): format="multipart", ) - self.assertEqual(response.status_code, 200) + from django.core.files.storage import default_storage + + # Verify that the old files are cleared from storage + self.assertTrue(LetterImage.objects.filter(file_name="new.bin").exists()) self.assertEqual(LetterImage.objects.count(), 1) + self.assertFalse(default_storage.exists("encrypted-images/old1.bin")) + self.assertFalse(default_storage.exists("encrypted-images/old2.bin")) + self.assertEqual(response.status_code, 200) class LetterImageModelTest(TestCase): diff --git a/backend/letters/views.py b/backend/letters/views.py index 1a8e44f..a89857f 100644 --- a/backend/letters/views.py +++ b/backend/letters/views.py @@ -36,6 +36,10 @@ class LetterDetailView(generics.RetrieveUpdateDestroyAPIView): # upsert: create if doesn't exist, else update letter, created = Letter.objects.get_or_create(public_id=public_id, user=request.user) + # check if already sealed + if not created and letter.status == Letter.Status.SEALED: + return Response({"error": "Sealed letters cannot be modified."}, status=400) + # request.data handles both JSON and Multipart automatically in DRF serializer = self.get_serializer(letter, data=request.data, partial=True) serializer.is_valid(raise_exception=True) @@ -43,7 +47,11 @@ class LetterDetailView(generics.RetrieveUpdateDestroyAPIView): # Note: image_files is a list of binary files in request.FILES if "image_files" in request.FILES: - letter.images.all().delete() + # Delete old image files from storage and database + for old_image in letter.images.all(): + old_image.file.delete(save=False) + old_image.delete() + for image_file in request.FILES.getlist("image_files"): LetterImage.objects.create(letter=letter, file=image_file, file_name=image_file.name)