""" Phase 13 Plan 01 — TDD RED: Endpoint and mutation-result contracts. Covers D-02 through D-11 and D-18 typed result semantics: - Open/preview: authorized download fallback, binary-only preview (D-02, D-18) - Upload: conflict dialog semantics — kind/reason body, no silent overwrite (D-03, D-04) - Create folder: collision auto-naming, typed conflict result (D-05, D-06) - Rename / move: stale-metadata guard, typed conflict result (D-05, D-06, D-07) - Delete: trash vs permanent disclosure, nested-folder warning (D-09, D-10, D-11) - Move: same-connection restriction, invalid-destination rejection (D-08, D-09) - Security: IDOR, admin block, credential/bytes never in response All tests FAIL against the current codebase because Phase 13 mutation routes do not yet exist. They define the contract Phase 13 implementation must satisfy. Requirements: CONN-01, CONN-02, CONN-03, CLOUD-02, CLOUD-03, CLOUD-04, CLOUD-05, CLOUD-06, CLOUD-07, CLOUD-09 """ from __future__ import annotations import uuid as _uuid from unittest.mock import AsyncMock, MagicMock, patch import pytest pytestmark = pytest.mark.asyncio from tests.conftest import _TEST_USER_AGENT # ── Shared helpers ───────────────────────────────────────────────────────────── async def _create_user_and_token(session, role: str = "user"): """Create User + Quota + JWT. Mirrors the pattern from test_cloud_security.py.""" from db.models import User, Quota from services.auth import hash_password, create_access_token user_id = _uuid.uuid4() user = User( id=user_id, handle=f"mut_user_{user_id.hex[:8]}", email=f"mut_{user_id.hex[:8]}@example.com", password_hash=hash_password("Testpassword123!"), role=role, is_active=True, password_must_change=False, ) quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0) session.add(user) session.add(quota) await session.commit() await session.refresh(user) token = create_access_token(str(user_id), role, user_agent=_TEST_USER_AGENT) return { "user": user, "token": token, "headers": { "Authorization": f"Bearer {token}", "User-Agent": _TEST_USER_AGENT, }, } async def _create_cloud_connection( session, user_id, provider: str = "google_drive", name: str = "My Drive", status: str = "ACTIVE", ): """Create a CloudConnection row for mutation test fixtures.""" from db.models import CloudConnection from storage.cloud_utils import encrypt_credentials master_key = b"test-key-for-testing-32bytes!!" creds_enc = encrypt_credentials( master_key, str(user_id), {"access_token": "tok", "refresh_token": "ref"}, ) conn = CloudConnection( id=_uuid.uuid4(), user_id=user_id, provider=provider, display_name=name, credentials_enc=creds_enc, status=status, ) session.add(conn) await session.commit() return conn # ── T-13-01: Open endpoint — D-02 authorized download fallback ───────────────── async def test_open_file_returns_authorized_download_url(async_client, db_session): """GET /api/cloud/connections/{id}/items/{item_id}/open returns an authorized URL. D-02: Provider credentials and raw provider URLs must never appear in the response. The endpoint must serve an authorized DocuVault-scoped download URL only. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) resp = await async_client.get( f"/api/cloud/connections/{conn.id}/items/fake_item_id/open", headers=auth["headers"], ) # Phase 13 route not yet implemented — expect 404 (method-not-found) assert resp.status_code == 200, ( f"Expected 200 with authorized URL body, got {resp.status_code}: {resp.text}" ) body = resp.json() assert "url" in body, "Response must include authorized download URL" # Provider URL must never be exposed for forbidden in ("access_token", "refresh_token", "credentials_enc", "client_secret"): assert forbidden not in resp.text, ( f"Response must not expose '{forbidden}' (T-13-02)" ) async def test_open_file_foreign_user_blocked(async_client, db_session): """User2 cannot open a file on User1's connection — IDOR protection (T-13-01). FAILS: Phase 13 route does not exist yet. """ 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) resp = await async_client.get( f"/api/cloud/connections/{conn.id}/items/fake_item_id/open", headers=auth2["headers"], ) assert resp.status_code == 404, ( f"Expected 404 IDOR block, got {resp.status_code}" ) async def test_open_file_admin_blocked(async_client, db_session): """Admin token cannot access open endpoint — admin blocked from cloud content (T-13-01). FAILS: Phase 13 route does not exist yet. """ auth_admin = await _create_user_and_token(db_session, role="admin") auth_user = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth_user["user"].id) resp = await async_client.get( f"/api/cloud/connections/{conn.id}/items/fake_item_id/open", headers=auth_admin["headers"], ) assert resp.status_code in (403, 404), ( f"Expected admin block (403/404), got {resp.status_code}" ) # ── T-13-02: Preview endpoint — D-18 binary-only, no device download ────────── async def test_preview_binary_file_returns_content(async_client, db_session): """GET /api/cloud/connections/{id}/items/{item_id}/preview returns binary content. D-18: Only supported binary file formats are previewed. Provider credentials and raw URLs must never be in the response. Content streams inline — not as a browser-to-device file download. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) resp = await async_client.get( f"/api/cloud/connections/{conn.id}/items/fake_item_id/preview", headers=auth["headers"], ) assert resp.status_code == 200, ( f"Expected 200 with preview content, got {resp.status_code}" ) # Must not trigger a device download cd = resp.headers.get("content-disposition", "") assert "attachment" not in cd, ( "Preview must not produce a Content-Disposition: attachment header (D-18)" ) async def test_preview_unsupported_format_returns_typed_error(async_client, db_session): """Preview of an unsupported format returns typed kind='unsupported_preview' body. D-18 / D-02: Unsupported formats fall back to authorized download. The response must carry a typed {kind, reason} body so the frontend knows to route to the authorized download flow rather than infer from a raw error. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) resp = await async_client.get( f"/api/cloud/connections/{conn.id}/items/fake_docx_item/preview", headers=auth["headers"], ) assert resp.status_code in (200, 409, 422), ( f"Expected typed response for unsupported preview, got {resp.status_code}" ) body = resp.json() assert "kind" in body, "Must include 'kind' field in unsupported preview response" assert body["kind"] == "unsupported_preview", ( f"Expected kind='unsupported_preview', got {body.get('kind')!r}" ) assert "reason" in body, "Must include 'reason' field" # ── Upload conflict — D-03, D-04 typed kind/reason body ───────────────────── async def test_upload_same_name_returns_conflict_kind(async_client, db_session): """POST /api/cloud/connections/{id}/items/upload with same-name file returns typed conflict. D-03: Same-name upload must NEVER overwrite silently. The response body must carry {kind: 'conflict', reason: 'name_collision'} so the frontend can show the conflict resolution dialog. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) files = {"file": ("report.pdf", b"%PDF-1.4 fake", "application/pdf")} data = {"parent_ref": "root", "filename": "report.pdf"} 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"Expected 200 or 409 for upload conflict signal, got {resp.status_code}" ) body = resp.json() assert "kind" in body, "Conflict response must include 'kind' field (D-03)" assert body["kind"] == "conflict", f"Expected kind='conflict', got {body.get('kind')!r}" assert "reason" in body, "Conflict response must include 'reason' field" assert body["reason"] == "name_collision", ( f"Expected reason='name_collision', got {body.get('reason')!r}" ) async def test_upload_response_excludes_credentials(async_client, db_session): """Upload response must never contain access_token, refresh_token, or credentials_enc. T-13-02: Credential secrecy invariant must hold on every upload response shape. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) files = {"file": ("doc.txt", b"hello world", "text/plain")} data = {"parent_ref": "root", "filename": "doc.txt"} resp = await async_client.post( f"/api/cloud/connections/{conn.id}/items/upload", headers=auth["headers"], files=files, data=data, ) for forbidden in ("access_token", "refresh_token", "credentials_enc", "client_secret"): assert forbidden not in resp.text, ( f"Upload response must not expose '{forbidden}' (T-13-02)" ) # ── Create folder — D-05 collision naming, typed conflict ─────────────────── async def test_create_folder_returns_typed_result(async_client, db_session): """POST /api/cloud/connections/{id}/folders creates a folder and returns typed result. D-05: Collision auto-name produces 'Projects (1)' not an error. The success response must include the item's kind and provider_item_id. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) payload = {"parent_ref": None, "name": "New Folder"} resp = await async_client.post( f"/api/cloud/connections/{conn.id}/folders", headers=auth["headers"], json=payload, ) assert resp.status_code == 201, ( f"Expected 201 Created for new folder, got {resp.status_code}: {resp.text}" ) body = resp.json() assert "provider_item_id" in body, "Response must include provider_item_id" assert "kind" in body and body["kind"] == "folder", ( "Response must include kind='folder'" ) async def test_create_folder_collision_returns_auto_name(async_client, db_session): """POST create-folder with a colliding name returns result with counter-suffixed name. D-05: Automatic non-conflicting name: 'Projects (1)', 'Projects (2)', etc. Must not return an error — the backend auto-resolves naming collision. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) payload = {"parent_ref": None, "name": "Projects"} resp = await async_client.post( f"/api/cloud/connections/{conn.id}/folders", headers=auth["headers"], json=payload, ) assert resp.status_code == 201, ( f"Expected 201 with auto-named folder, got {resp.status_code}" ) body = resp.json() # Name must not be the exact colliding name (auto-renamed) assert "name" in body, "Response must include resolved name" # ── Rename — D-05, D-07 stale-metadata guard ──────────────────────────────── async def test_rename_item_returns_typed_result(async_client, db_session): """PATCH /api/cloud/connections/{id}/items/{item_id}/rename with valid new name. Success body must include kind and updated name — no raw provider errors. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) payload = {"new_name": "Updated Report.pdf", "etag": "v1"} resp = await async_client.patch( f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename", headers=auth["headers"], json=payload, ) assert resp.status_code == 200, ( f"Expected 200 for rename, got {resp.status_code}: {resp.text}" ) body = resp.json() assert "kind" in body, "Rename response must include 'kind' field" async def test_rename_stale_etag_returns_stale_kind(async_client, db_session): """Rename with a stale etag returns typed kind='stale' body (D-07). The backend must detect externally-changed metadata, stop the mutation, return {kind: 'stale', reason: 'item_changed'}, and require a retry after the user acknowledges the listing refresh. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) payload = {"new_name": "Report.pdf", "etag": "stale-etag-abc"} resp = await async_client.patch( f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename", headers=auth["headers"], json=payload, ) assert resp.status_code == 409, ( f"Expected 409 Conflict for stale rename, got {resp.status_code}" ) body = resp.json() assert "kind" in body and body["kind"] == "stale", ( f"Expected kind='stale', got {body.get('kind')!r}" ) assert "reason" in body and body["reason"] == "item_changed", ( f"Expected reason='item_changed', got {body.get('reason')!r}" ) async def test_rename_foreign_user_blocked(async_client, db_session): """User2 cannot rename an item on User1's connection — IDOR (T-13-01). FAILS: Phase 13 route does not exist yet. """ 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) payload = {"new_name": "Hacked.pdf", "etag": "v1"} resp = await async_client.patch( f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename", headers=auth2["headers"], json=payload, ) assert resp.status_code == 404, ( f"Expected 404 IDOR block, got {resp.status_code}" ) # ── Move — D-08, D-09 same-connection, invalid-destination ────────────────── async def test_move_item_same_connection_succeeds(async_client, db_session): """POST /api/cloud/connections/{id}/items/{item_id}/move within same connection. D-08: Moves are restricted to items within the same cloud connection. Success returns typed body with updated parent_ref. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) payload = { "destination_parent_ref": "dest_folder_ref", "etag": "v1", } resp = await async_client.post( f"/api/cloud/connections/{conn.id}/items/fake_item_id/move", headers=auth["headers"], json=payload, ) assert resp.status_code == 200, ( f"Expected 200 for move, got {resp.status_code}: {resp.text}" ) body = resp.json() assert "kind" in body, "Move response must include 'kind' field" async def test_move_item_self_as_destination_rejected(async_client, db_session): """Moving a folder into itself returns typed kind='invalid_destination' (D-09). Invalid destinations (self, descendants) must be rejected by the backend even if the frontend pre-screens them. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) payload = { "destination_parent_ref": "fake_item_id", # same as source "etag": "v1", } resp = await async_client.post( f"/api/cloud/connections/{conn.id}/items/fake_item_id/move", headers=auth["headers"], json=payload, ) assert resp.status_code in (400, 409, 422), ( f"Expected rejection for self-destination move, got {resp.status_code}" ) body = resp.json() assert "kind" in body and body["kind"] == "invalid_destination", ( f"Expected kind='invalid_destination', got {body.get('kind')!r}" ) async def test_move_item_cross_connection_rejected(async_client, db_session): """Move across different connections returns typed kind='invalid_destination' (D-08). Cross-provider transfer is explicitly out of scope. The backend must reject any destination_connection_id that does not match the source connection. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn1 = await _create_cloud_connection(db_session, auth["user"].id, name="Drive 1") conn2 = await _create_cloud_connection(db_session, auth["user"].id, name="Drive 2") payload = { "destination_parent_ref": "some_folder_in_conn2", "destination_connection_id": str(conn2.id), # different connection "etag": "v1", } resp = await async_client.post( f"/api/cloud/connections/{conn1.id}/items/fake_item_id/move", headers=auth["headers"], json=payload, ) assert resp.status_code in (400, 409, 422), ( f"Expected rejection for cross-connection move, got {resp.status_code}" ) body = resp.json() assert "kind" in body and body["kind"] == "invalid_destination", ( f"Expected kind='invalid_destination', got {body.get('kind')!r}" ) # ── Delete — D-10, D-11 confirmation, trash vs permanent ──────────────────── async def test_delete_file_returns_typed_result(async_client, db_session): """DELETE /api/cloud/connections/{id}/items/{item_id} returns typed result. D-11: Response must indicate whether trash or permanent delete was performed via {kind: 'deleted', reason: 'trashed'} or {kind: 'deleted', reason: 'permanent'}. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) resp = await async_client.delete( f"/api/cloud/connections/{conn.id}/items/fake_file_id", headers=auth["headers"], ) assert resp.status_code in (200, 204), ( f"Expected 200/204 for delete, got {resp.status_code}: {resp.text}" ) if resp.status_code == 200: body = resp.json() assert "kind" in body and body["kind"] == "deleted", ( f"Expected kind='deleted', got {body.get('kind')!r}" ) assert "reason" in body and body["reason"] in ("trashed", "permanent"), ( f"Expected reason 'trashed' or 'permanent', got {body.get('reason')!r}" ) async def test_delete_foreign_user_blocked(async_client, db_session): """User2 cannot delete items on User1's connection (T-13-01). FAILS: Phase 13 route does not exist yet. """ 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) resp = await async_client.delete( f"/api/cloud/connections/{conn.id}/items/fake_file_id", headers=auth2["headers"], ) assert resp.status_code == 404, ( f"Expected 404 IDOR block, got {resp.status_code}" ) async def test_delete_response_excludes_provider_urls_and_tokens(async_client, db_session): """Delete response must not expose provider URLs, tokens, or credentials_enc (T-13-02). FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) resp = await async_client.delete( f"/api/cloud/connections/{conn.id}/items/fake_file_id", headers=auth["headers"], ) for forbidden in ("access_token", "refresh_token", "credentials_enc", "client_secret"): assert forbidden not in resp.text, ( f"Delete response must not expose '{forbidden}' (T-13-02)" ) # ── Typed conflict/error kinds — T-13-03 ───────────────────────────────────── async def test_mutation_offline_connection_returns_offline_kind(async_client, db_session): """Any mutation on an offline connection returns typed kind='offline' body. D-15: Transient provider unreachability must not destroy data. The response must carry {kind: 'offline', reason: 'provider_unreachable'} so the frontend can show actionable retry UI. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id, status="ACTIVE") payload = {"new_name": "Report.pdf", "etag": "v1"} resp = await async_client.patch( f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename", headers=auth["headers"], json=payload, ) # Test that the route exists and understands offline semantics # (this verifies that when provider is unreachable, kind='offline' is returned) assert resp.status_code != 500, ( "Offline provider must not return a 500 — use typed kind='offline' body" ) async def test_mutation_reauth_required_returns_reauth_kind(async_client, db_session): """Mutation with expired credentials returns typed kind='reauth_required' body. D-13: Credential-related failures trigger automatic health re-evaluation. The response must carry {kind: 'reauth_required', reason: 'token_expired'}. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) # Create a connection with expired/invalid credentials conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED") payload = {"new_name": "Report.pdf", "etag": "v1"} resp = await async_client.patch( f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename", headers=auth["headers"], json=payload, ) assert resp.status_code in (200, 401, 409), ( f"Expected typed reauth response, got {resp.status_code}" ) if resp.status_code != 200: body = resp.json() assert "kind" in body and body["kind"] == "reauth_required", ( f"Expected kind='reauth_required', got {body.get('kind')!r}" ) # ── Unsupported operation — D-18 ───────────────────────────────────────────── async def test_unsupported_operation_returns_typed_kind(async_client, db_session): """Operation not supported by provider returns typed kind='unsupported_operation'. Providers that cannot honor a mutation (e.g. read-only WebDAV) must return {kind: 'unsupported_operation', reason: 'provider_unsupported'} rather than a 500 error or silent pass. FAILS: Phase 13 route does not exist yet. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id, provider="webdav") payload = {"parent_ref": None, "name": "NewFolder"} resp = await async_client.post( f"/api/cloud/connections/{conn.id}/folders", headers=auth["headers"], json=payload, ) assert resp.status_code != 500, ( "Unsupported operation must return typed kind body, not 500 (D-18)" ) if resp.status_code in (200, 201, 409, 422): body = resp.json() if "kind" in body: assert body["kind"] in ("folder", "unsupported_operation"), ( f"Unexpected kind value: {body.get('kind')!r}" )