Skip to content

Commit e7cf3ef

Browse files
committed
Add per-user storage visibility and document delete flow
1 parent 73b12ac commit e7cf3ef

8 files changed

Lines changed: 1585 additions & 35 deletions

File tree

backend/lambda_handler.py

Lines changed: 946 additions & 6 deletions
Large diffs are not rendered by default.

backend/template.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ Resources:
108108
FRONTEND_EVENTS_TABLE: !Ref FrontendEventsTable
109109
WAITLIST_TABLE: !Ref WaitlistTable
110110
ASSETS_BUCKET: !Ref AssetsBucket
111+
MAX_USER_STORAGE_BYTES: "5368709120"
111112
Policies:
112113
- DynamoDBCrudPolicy:
113114
TableName: !Ref SessionsTable
@@ -180,6 +181,12 @@ Resources:
180181
Path: /documents
181182
Method: GET
182183
RestApiId: !Ref FPAIApi
184+
DeleteDocument:
185+
Type: Api
186+
Properties:
187+
Path: /documents
188+
Method: DELETE
189+
RestApiId: !Ref FPAIApi
183190
DocumentDownload:
184191
Type: Api
185192
Properties:

backend/tests/test_lambda_handler.py

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
507669
def 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+
673971
def 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+
20052324
def 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

frontend/src/components/app/AppShell.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ function ShellInner({ children }: { children: React.ReactNode }) {
7575
memory: memoryRes.memory,
7676
hasMemory: memoryRes.hasMemory,
7777
tokenUsage: statusRes.usage,
78+
storageUsage: statusRes.storage,
7879
})
7980
setMemorySnapshot(memoryRes.memory)
8081
setOnboardingRequired(!hasCompletedOnboarding(memoryRes.memory))

0 commit comments

Comments
 (0)