diff --git a/backend/tests/test_cloud_mutations.py b/backend/tests/test_cloud_mutations.py index 0ac1ae2..c41ccb7 100644 --- a/backend/tests/test_cloud_mutations.py +++ b/backend/tests/test_cloud_mutations.py @@ -778,3 +778,270 @@ async def test_unsupported_operation_returns_typed_kind(async_client, db_session assert body["kind"] in ("folder", "unsupported_operation"), ( f"Unexpected kind value: {body.get('kind')!r}" ) + + +# ── Phase 13 Plan 05 Task 2: Upload route typed mechanics ───────────────────── + + +async def test_upload_success_returns_typed_uploaded_body(async_client, db_session): + """POST upload with no conflict returns {kind: 'uploaded', provider_item_id, name, size}. + + Task 2 behavior 1: The upload route must return a stable typed 'uploaded' body so + the queue can advance to the next file without Vue-side inference. + Mock adapter returns the canonical uploaded result. + """ + from storage.cloud_base import MUT_KIND_UPLOADED, MUT_REASON_CREATED + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id) + + import uuid as _uuid2 + mock_adapter = _make_mock_mutable_adapter( + upload_result={ + "kind": MUT_KIND_UPLOADED, + "reason": MUT_REASON_CREATED, + "provider_item_id": str(_uuid2.uuid4()), + "name": "report.pdf", + "parent_ref": "root", + "size": 1024, + } + ) + + files = {"file": ("report.pdf", b"%PDF-1.4 test", "application/pdf")} + data = {"parent_ref": "root_no_conflict", "filename": "report.pdf"} + + with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter): + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/items/upload", + headers=auth["headers"], + files=files, + data=data, + ) + + assert resp.status_code == 200, ( + f"Expected 200 for upload success, got {resp.status_code}: {resp.text}" + ) + body = resp.json() + assert body.get("kind") == "uploaded", ( + f"Expected kind='uploaded', got {body.get('kind')!r}" + ) + assert "provider_item_id" in body, "Response must include provider_item_id" + assert "name" in body, "Response must include name" + assert "size" in body, "Response must include size" + + +async def test_upload_provider_retryable_error_returns_offline_kind(async_client, db_session): + """POST upload on provider 503 returns typed {kind: 'offline', reason: 'provider_offline'}. + + Task 2 behavior 1: A transient provider error must pause the upload queue + (D-04). The route must expose the typed 'offline' result so Vue can present + the 'Retry / Skip / Cancel all' dialog — never a bare 500. + """ + from storage.cloud_base import MUT_KIND_OFFLINE, MUT_REASON_PROVIDER_OFFLINE + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id) + + mock_adapter = _make_mock_mutable_adapter( + upload_result={ + "kind": MUT_KIND_OFFLINE, + "reason": MUT_REASON_PROVIDER_OFFLINE, + } + ) + + files = {"file": ("report.pdf", b"data", "application/pdf")} + data = {"parent_ref": "root", "filename": "retryable_file.pdf"} + + with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter): + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/items/upload", + headers=auth["headers"], + files=files, + data=data, + ) + + # Route must not raise a bare 500 — must return typed error + assert resp.status_code != 500, ( + f"Retryable error must not produce a 500 (D-04), got {resp.status_code}: {resp.text}" + ) + body = resp.json() + assert "kind" in body, "Retryable error response must include 'kind' field (D-04)" + # kind must be a recognized non-success result + assert body.get("kind") in ("offline", "error", "reauth_required"), ( + f"Expected typed error kind, got {body.get('kind')!r}" + ) + + +async def test_upload_provider_conflict_returns_conflict_kind(async_client, db_session): + """POST upload when provider detects conflict returns typed {kind: 'conflict'} body. + + Task 2 behavior 1: If the fast-path cache check misses but the provider + returns a conflict (e.g. OneDrive 409 on createUploadSession), the route must + surface the typed conflict body — not a raw 409 error — so the queue can show + the conflict dialog. + """ + from storage.cloud_base import MUT_KIND_CONFLICT, MUT_REASON_NAME_COLLISION + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id) + + mock_adapter = _make_mock_mutable_adapter( + upload_result={ + "kind": MUT_KIND_CONFLICT, + "reason": MUT_REASON_NAME_COLLISION, + "existing_name": "document.pdf", + } + ) + + # No cached CloudItem — so fast path won't block + files = {"file": ("document.pdf", b"data", "application/pdf")} + data = {"parent_ref": "folder_no_cache", "filename": "document.pdf"} + + with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter): + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/items/upload", + headers=auth["headers"], + files=files, + data=data, + ) + + assert resp.status_code in (200, 409), ( + f"Conflict must produce 200 or 409, not {resp.status_code}" + ) + body = resp.json() + assert body.get("kind") == "conflict", ( + f"Expected kind='conflict', got {body.get('kind')!r}" + ) + assert body.get("reason") == "name_collision", ( + f"Expected reason='name_collision', got {body.get('reason')!r}" + ) + + +async def test_upload_reauth_result_is_typed_and_not_500(async_client, db_session): + """POST upload when provider requires reauth returns typed {kind: 'reauth_required'}. + + Task 2 behavior 2: CONN-02 — refreshed credentials surface upward. The route + must return typed reauth body so the queue knows to prompt reconnect. + """ + from storage.cloud_base import MUT_KIND_REAUTH, MUT_REASON_TOKEN_EXPIRED + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id) + + mock_adapter = _make_mock_mutable_adapter( + upload_result={ + "kind": MUT_KIND_REAUTH, + "reason": MUT_REASON_TOKEN_EXPIRED, + } + ) + + files = {"file": ("report.pdf", b"data", "application/pdf")} + data = {"parent_ref": "root", "filename": "auth_report.pdf"} + + with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter): + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/items/upload", + headers=auth["headers"], + files=files, + data=data, + ) + + assert resp.status_code != 500, ( + f"Reauth result must not produce 500, got {resp.status_code}" + ) + body = resp.json() + assert "kind" in body, "Reauth response must include 'kind'" + # Route may map reauth to a 4xx with typed body + assert body.get("kind") in ("reauth_required", "error"), ( + f"Unexpected kind for reauth: {body.get('kind')!r}" + ) + + +async def test_upload_queue_control_semantics_are_backend_authored(async_client, db_session): + """Upload response body contains all fields the queue needs to decide next action. + + Task 2 behavior 3: The queue-control fields (kind, reason, existing_name) must + come from the backend response. Vue must never infer queue state from HTTP status + alone — the body must have enough typed data. + + Success: body has kind, provider_item_id, name, size. + Conflict: body has kind, reason, existing_name. + """ + from storage.cloud_base import MUT_KIND_UPLOADED, MUT_REASON_CREATED + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id) + + import uuid as _uuid2 + mock_adapter = _make_mock_mutable_adapter( + upload_result={ + "kind": MUT_KIND_UPLOADED, + "reason": MUT_REASON_CREATED, + "provider_item_id": str(_uuid2.uuid4()), + "name": "presentation.pdf", + "parent_ref": "root", + "size": 512, + } + ) + + files = {"file": ("presentation.pdf", b"pptx", "application/pdf")} + data = {"parent_ref": "root_unique_2", "filename": "presentation.pdf"} + + with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter): + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/items/upload", + headers=auth["headers"], + files=files, + data=data, + ) + + assert resp.status_code == 200 + body = resp.json() + + # All queue-control fields must be present in success response + required_fields = {"kind", "provider_item_id", "name", "size"} + missing = required_fields - set(body.keys()) + assert not missing, ( + f"Upload success body missing queue-control fields: {missing} (Task 2 behavior 3)" + ) + assert body["kind"] == "uploaded", f"Expected kind='uploaded', got {body['kind']!r}" + + +async def test_upload_keep_both_name_format(async_client, db_session): + """keep_both_name() produces counter-before-extension format for collision resolution. + + Task 2: The service utility must be accessible from the route context for + queue-side rename. The format must match 'Report (1).pdf' not 'Report.pdf (1)'. + """ + from services.cloud_operations import keep_both_name + + # Verify the canonical keep-both naming format used by the upload queue + assert keep_both_name("Report.pdf", 1) == "Report (1).pdf", ( + "keep_both_name must insert counter BEFORE extension (D-03)" + ) + assert keep_both_name("archive.tar.gz", 1) == "archive (1).tar.gz", ( + "keep_both_name must handle compound extensions" + ) + assert keep_both_name("Report.pdf", 3) == "Report (3).pdf", ( + "keep_both_name must use the passed counter value" + ) + + +async def test_upload_foreign_user_blocked(async_client, db_session): + """User2 cannot upload to User1's connection — IDOR protection (T-13-01).""" + auth1 = await _create_user_and_token(db_session) + auth2 = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth1["user"].id) + + files = {"file": ("test.pdf", b"data", "application/pdf")} + data = {"parent_ref": "root"} + + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/items/upload", + headers=auth2["headers"], + files=files, + data=data, + ) + assert resp.status_code == 404, ( + f"Expected 404 IDOR block for foreign upload, got {resp.status_code}" + )