@@ -504,6 +504,168 @@ def test_get_presigned_url_success_includes_s3_refs():
504504 assert body ["s3_key" ].startswith ("uploads/" )
505505
506506
507+ def test_storage_quota_reservation_accepts_exact_boundary (monkeypatch ):
508+ monkeypatch .delenv ("USERS_TABLE" , raising = False )
509+ monkeypatch .setenv ("MAX_USER_STORAGE_BYTES" , "100" )
510+ user_id = "quota-boundary-user"
511+ lambda_handler .user_store .delete (user_id )
512+
513+ lambda_handler ._ensure_user_storage_backfilled (user_id )
514+ ok1 , _ = lambda_handler ._reserve_storage_quota_atomic (user_id , requested_bytes = 60 )
515+ ok2 , _ = lambda_handler ._reserve_storage_quota_atomic (user_id , requested_bytes = 40 )
516+ ok3 , snapshot = lambda_handler ._reserve_storage_quota_atomic (user_id , requested_bytes = 1 )
517+
518+ assert ok1 is True
519+ assert ok2 is True
520+ assert ok3 is False
521+ assert snapshot ["used_bytes" ] + snapshot ["reserved_bytes" ] == snapshot ["limit_bytes" ]
522+
523+
524+ def test_get_presigned_url_returns_413_when_storage_limit_exceeded (monkeypatch ):
525+ event = {
526+ "httpMethod" : "POST" ,
527+ "path" : "/uploads/presigned" ,
528+ "body" : json .dumps (
529+ {
530+ "filename" : "notes.md" ,
531+ "file_type" : "text/markdown" ,
532+ "size_bytes" : 1024 ,
533+ }
534+ ),
535+ }
536+ snapshot = {
537+ "used_bytes" : 90 ,
538+ "reserved_bytes" : 10 ,
539+ "limit_bytes" : 100 ,
540+ "available_bytes" : 0 ,
541+ }
542+
543+ with patch .dict ("os.environ" , {"ASSETS_BUCKET" : "bucket" }), patch (
544+ "lambda_handler._reserve_storage_quota_atomic"
545+ ) as mock_reserve :
546+ mock_reserve .return_value = (False , snapshot )
547+ response = handler (event , None )
548+
549+ assert response ["statusCode" ] == 413
550+ body = json .loads (response ["body" ])
551+ assert body ["error" ] == "Storage limit exceeded"
552+ assert body ["storage" ] == snapshot
553+
554+
555+ def test_storage_backfill_runs_once_per_user (monkeypatch ):
556+ monkeypatch .delenv ("USERS_TABLE" , raising = False )
557+ user_id = "storage-backfill-user"
558+ lambda_handler .user_store .delete (user_id )
559+
560+ with patch ("lambda_handler._sum_user_upload_bytes" ) as mock_sum :
561+ mock_sum .return_value = 123
562+ first = lambda_handler ._ensure_user_storage_backfilled (user_id )
563+ second = lambda_handler ._ensure_user_storage_backfilled (user_id )
564+
565+ assert first ["used_bytes" ] == 123
566+ assert second ["used_bytes" ] == 123
567+ assert mock_sum .call_count == 1
568+
569+
570+ def test_cleanup_stale_upload_preps_releases_stale_reservation (monkeypatch ):
571+ stale_row = {
572+ "upload_id" : "resv_1" ,
573+ "kind" : "storage_reservation" ,
574+ "owner_user_id" : "user-1" ,
575+ "status" : "reserved" ,
576+ "reserved_size_bytes" : 500 ,
577+ "s3_bucket" : "bucket" ,
578+ "s3_key" : "uploads/user-1/u1/file.txt" ,
579+ "created_at" : "2020-01-01T00:00:00" ,
580+ }
581+ table = MagicMock ()
582+ table .scan .return_value = {"Items" : [stale_row ]}
583+ s3_client = MagicMock ()
584+
585+ monkeypatch .setattr (lambda_handler , "Key" , None )
586+ with patch ("lambda_handler.get_upload_prep_table" , return_value = table ), patch (
587+ "lambda_handler.get_aws_client" , return_value = s3_client
588+ ), patch ("lambda_handler._release_reserved_storage" , return_value = True ) as mock_release :
589+ result = lambda_handler ._cleanup_stale_upload_preps ({"source" : "aws.events" })
590+
591+ assert result ["success" ] is True
592+ assert result ["deleted" ] == 1
593+ mock_release .assert_called_once_with ("user-1" , reservation_id = "resv_1" , reserved_size_bytes = 500 )
594+ table .delete_item .assert_called_once_with (Key = {"upload_id" : "resv_1" })
595+
596+
597+ def test_cleanup_stale_upload_preps_decrements_used_for_stale_prepared_upload (monkeypatch ):
598+ stale_row = {
599+ "upload_id" : "up_1" ,
600+ "kind" : "prepared_upload" ,
601+ "owner_user_id" : "user-1" ,
602+ "status" : "ready" ,
603+ "storage_committed" : True ,
604+ "storage_committed_bytes" : 321 ,
605+ "created_at" : "2020-01-01T00:00:00" ,
606+ }
607+ table = MagicMock ()
608+ table .scan .return_value = {"Items" : [stale_row ]}
609+ s3_client = MagicMock ()
610+
611+ monkeypatch .setattr (lambda_handler , "Key" , None )
612+ with patch ("lambda_handler.get_upload_prep_table" , return_value = table ), patch (
613+ "lambda_handler.get_aws_client" , return_value = s3_client
614+ ), patch ("lambda_handler._decrement_used_storage" , return_value = True ) as mock_decrement :
615+ result = lambda_handler ._cleanup_stale_upload_preps ({"source" : "aws.events" })
616+
617+ assert result ["success" ] is True
618+ mock_decrement .assert_called_once_with ("user-1" , used_size_bytes = 321 )
619+ table .delete_item .assert_called_once_with (Key = {"upload_id" : "up_1" })
620+
621+
622+ def test_cleanup_stale_upload_preps_ignores_document_tombstones (monkeypatch ):
623+ stale_row = {
624+ "upload_id" : "docdel_1" ,
625+ "kind" : "document_tombstone" ,
626+ "owner_user_id" : "user-1" ,
627+ "status" : "deleted" ,
628+ "created_at" : "2020-01-01T00:00:00" ,
629+ }
630+ table = MagicMock ()
631+ table .scan .return_value = {"Items" : [stale_row ]}
632+ s3_client = MagicMock ()
633+
634+ monkeypatch .setattr (lambda_handler , "Key" , None )
635+ with patch ("lambda_handler.get_upload_prep_table" , return_value = table ), patch (
636+ "lambda_handler.get_aws_client" , return_value = s3_client
637+ ):
638+ result = lambda_handler ._cleanup_stale_upload_preps ({"source" : "aws.events" })
639+
640+ assert result ["success" ] is True
641+ assert result ["scanned" ] == 1
642+ assert result ["deleted" ] == 0
643+ table .delete_item .assert_not_called ()
644+
645+
646+ def test_status_includes_storage_usage ():
647+ event = {
648+ "httpMethod" : "GET" ,
649+ "path" : "/status" ,
650+ }
651+ snapshot = {
652+ "used_bytes" : 10 ,
653+ "reserved_bytes" : 5 ,
654+ "limit_bytes" : 100 ,
655+ "available_bytes" : 85 ,
656+ }
657+
658+ with patch ("lambda_handler._ensure_user_storage_backfilled" , return_value = snapshot ), patch (
659+ "lambda_handler.user_store"
660+ ) as mock_user_store :
661+ mock_user_store .get .return_value = None
662+ response = handler (event , None )
663+
664+ assert response ["statusCode" ] == 200
665+ body = json .loads (response ["body" ])
666+ assert body ["storage" ] == snapshot
667+
668+
507669def test_documents_endpoint_lists_non_image_attachments ():
508670 event = {
509671 "httpMethod" : "GET" ,
@@ -670,6 +832,142 @@ def test_documents_endpoint_returns_partial_data_when_a_session_fails():
670832 assert body ["documents" ][0 ]["name" ] == "good.txt"
671833
672834
835+ def test_documents_endpoint_excludes_tombstoned_documents ():
836+ event = {
837+ "httpMethod" : "GET" ,
838+ "path" : "/documents" ,
839+ }
840+
841+ with patch ("lambda_handler.session_store" ) as mock_store , patch (
842+ "lambda_handler._is_document_tombstoned" , return_value = True
843+ ):
844+ mock_store .list_by_user .return_value = {
845+ "sessions" : [{"id" : "session-1" }],
846+ "next_cursor" : None ,
847+ }
848+ mock_session = MagicMock ()
849+ mock_session .is_hidden = False
850+ mock_session .title = "Roadmap notes"
851+ mock_session .updated_at = "2026-02-27T00:00:00"
852+ mock_session .messages = [
853+ {
854+ "role" : "user" ,
855+ "attachments" : [
856+ {
857+ "name" : "paper.pdf" ,
858+ "type" : "application/pdf" ,
859+ "s3_key" : "uploads/user-1/paper.pdf" ,
860+ "s3_bucket" : "bucket" ,
861+ "size_bytes" : 1024 ,
862+ }
863+ ],
864+ }
865+ ]
866+ mock_store .get .return_value = mock_session
867+
868+ response = handler (event , None )
869+
870+ assert response ["statusCode" ] == 200
871+ body = json .loads (response ["body" ])
872+ assert body ["success" ] is True
873+ assert body ["documents" ] == []
874+
875+
876+ def test_delete_document_success_path_returns_storage_snapshot ():
877+ event = {
878+ "queryStringParameters" : {
879+ "s3_key" : "uploads/user-1/docs/file.pdf" ,
880+ "s3_bucket" : "assets-bucket" ,
881+ }
882+ }
883+ snapshot = {
884+ "used_bytes" : 100 ,
885+ "reserved_bytes" : 0 ,
886+ "limit_bytes" : 1000 ,
887+ "available_bytes" : 900 ,
888+ }
889+ mock_table = MagicMock ()
890+ mock_s3 = MagicMock ()
891+ mock_s3 .head_object .return_value = {"ContentLength" : 321 }
892+
893+ with patch ("lambda_handler._get_authenticated_user" ) as mock_auth , patch (
894+ "lambda_handler._upload_key_belongs_to_user" , return_value = True
895+ ), patch ("lambda_handler._ensure_user_storage_backfilled" ), patch (
896+ "lambda_handler._get_document_tombstone" , return_value = {}
897+ ), patch (
898+ "lambda_handler.get_upload_prep_table" , return_value = mock_table
899+ ), patch (
900+ "lambda_handler.get_aws_client" , return_value = mock_s3
901+ ), patch (
902+ "lambda_handler._decrement_used_storage" , return_value = True
903+ ) as mock_decrement , patch (
904+ "lambda_handler._load_user_storage_snapshot" , return_value = snapshot
905+ ):
906+ mock_auth .return_value = lambda_handler .AuthenticatedUser (user_id = "user-1" )
907+ response = lambda_handler .handle_delete_document (event )
908+
909+ assert response ["statusCode" ] == 200
910+ body = json .loads (response ["body" ])
911+ assert body ["success" ] is True
912+ assert body ["deleted" ] is True
913+ assert body ["storage" ] == snapshot
914+ mock_s3 .delete_object .assert_called_once_with (Bucket = "assets-bucket" , Key = "uploads/user-1/docs/file.pdf" )
915+ mock_decrement .assert_called_once_with ("user-1" , used_size_bytes = 321 )
916+ mock_table .put_item .assert_called_once ()
917+ mock_table .update_item .assert_called_once ()
918+
919+
920+ def test_delete_document_idempotent_when_already_deleted ():
921+ event = {
922+ "queryStringParameters" : {
923+ "s3_key" : "uploads/user-1/docs/file.pdf" ,
924+ "s3_bucket" : "assets-bucket" ,
925+ }
926+ }
927+ snapshot = {
928+ "used_bytes" : 10 ,
929+ "reserved_bytes" : 0 ,
930+ "limit_bytes" : 1000 ,
931+ "available_bytes" : 990 ,
932+ }
933+
934+ with patch ("lambda_handler._get_authenticated_user" ) as mock_auth , patch (
935+ "lambda_handler._upload_key_belongs_to_user" , return_value = True
936+ ), patch (
937+ "lambda_handler._get_document_tombstone" , return_value = {"status" : "deleted" }
938+ ), patch (
939+ "lambda_handler._load_user_storage_snapshot" , return_value = snapshot
940+ ), patch ("lambda_handler.get_upload_prep_table" ) as mock_get_table :
941+ mock_auth .return_value = lambda_handler .AuthenticatedUser (user_id = "user-1" )
942+ response = lambda_handler .handle_delete_document (event )
943+
944+ assert response ["statusCode" ] == 200
945+ body = json .loads (response ["body" ])
946+ assert body ["success" ] is True
947+ assert body ["deleted" ] is False
948+ assert body ["storage" ] == snapshot
949+ mock_get_table .assert_not_called ()
950+
951+
952+ def test_delete_document_forbidden_on_non_owned_key ():
953+ event = {
954+ "queryStringParameters" : {
955+ "s3_key" : "legacy/other-user/file.pdf" ,
956+ "s3_bucket" : "assets-bucket" ,
957+ }
958+ }
959+
960+ with patch ("lambda_handler._get_authenticated_user" ) as mock_auth , patch (
961+ "lambda_handler._upload_key_belongs_to_user" , return_value = False
962+ ):
963+ mock_auth .return_value = lambda_handler .AuthenticatedUser (user_id = "user-1" )
964+ response = lambda_handler .handle_delete_document (event )
965+
966+ assert response ["statusCode" ] == 403
967+ body = json .loads (response ["body" ])
968+ assert body ["success" ] is False
969+
970+
673971def test_images_invalid_limit_post_body ():
674972 """Invalid POST limit should return 400."""
675973 event = {
@@ -2002,6 +2300,27 @@ def test_documents_download_prefers_session_attachment_bucket():
20022300 assert kwargs ["Params" ]["Bucket" ] == "correct-bucket"
20032301
20042302
2303+ def test_documents_download_returns_410_when_document_tombstoned ():
2304+ event = {
2305+ "queryStringParameters" : {
2306+ "s3_key" : "uploads/user-1/example.pdf" ,
2307+ "s3_bucket" : "assets-bucket" ,
2308+ "name" : "example.pdf" ,
2309+ }
2310+ }
2311+
2312+ with patch ("lambda_handler._get_authenticated_user" ) as mock_auth , patch (
2313+ "lambda_handler._upload_key_belongs_to_user" , return_value = True
2314+ ), patch ("lambda_handler._is_document_tombstoned" , return_value = True ):
2315+ mock_auth .return_value = lambda_handler .AuthenticatedUser (user_id = "user-1" )
2316+ response = lambda_handler .handle_get_document_download_url (event )
2317+
2318+ assert response ["statusCode" ] == 410
2319+ body = json .loads (response ["body" ])
2320+ assert body ["success" ] is False
2321+ assert "deleted" in body ["error" ].lower ()
2322+
2323+
20052324def test_frontend_events_ingestion_accepts_valid_batch (monkeypatch ):
20062325 monkeypatch .setenv ("FRONTEND_EVENTS_TABLE" , "fpai-frontend-events-test" )
20072326 lambda_handler ._frontend_events_table = None
0 commit comments