From efb596433c787f16b2a467df3328a5e45a170228 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 22 Jun 2026 17:57:55 +0200 Subject: [PATCH] test(13-01): add red API and audit contracts for reconnect, content, and mutation flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create test_cloud_mutations.py: IDOR, admin block, credential secrecy, and typed kind/reason body coverage for open, preview, upload, create-folder, rename, move, and delete endpoint contracts (D-02 through D-11, D-18) - Create test_cloud_reconnect.py: reconnect patch-in-place (CONN-01), encrypted credential persistence (CONN-02), response secrecy (CONN-03), health endpoint, test action, cache-preservation on reconnect (D-14), transient-outage data preservation (D-15), and disconnect metadata cleanup (D-16) - Create test_cloud_audit.py: metadata-only audit rows for every successful cloud operation, false-overwrite prevention (T-13-05), admin audit log credential exclusion (T-13-02) - All tests fail against current codebase — Phase 13 routes do not exist yet (expected RED) --- backend/tests/test_cloud_audit.py | 549 ++++++++++++++++++++++ backend/tests/test_cloud_mutations.py | 653 ++++++++++++++++++++++++++ backend/tests/test_cloud_reconnect.py | 561 ++++++++++++++++++++++ 3 files changed, 1763 insertions(+) create mode 100644 backend/tests/test_cloud_audit.py create mode 100644 backend/tests/test_cloud_mutations.py create mode 100644 backend/tests/test_cloud_reconnect.py diff --git a/backend/tests/test_cloud_audit.py b/backend/tests/test_cloud_audit.py new file mode 100644 index 0000000..4dccf2b --- /dev/null +++ b/backend/tests/test_cloud_audit.py @@ -0,0 +1,549 @@ +""" +Phase 13 Plan 01 — TDD RED: Metadata-only audit trail contracts for cloud operations. + +Covers T-13-02 and T-13-05 — audit secrecy and accuracy: + - Successful cloud operations must write audit rows with metadata-only payloads. + - No provider URL, access_token, refresh_token, credentials_enc, or provider- + owned bytes may appear in any audit row's metadata_ JSONB column. + - Audit rows must accurately reflect the operation performed (no false overwrite events). + - Audit event types must be well-defined and consistently named across operations. + - Admin audit log viewer must never expose cloud credentials through audit entries. + +Covered operations: + reconnect, test, open, preview, upload (success + conflict), create_folder, + rename (success + stale), move (success + invalid), delete. + +All tests FAIL against the current codebase because Phase 13 audit writes +do not yet exist. + +Requirements: CLOUD-02 through CLOUD-07, CLOUD-09 +Threats: T-13-02 (content integrity), T-13-05 (audit trail accuracy) +""" +from __future__ import annotations + +import uuid as _uuid +from typing import Optional + +import pytest +from sqlalchemy import select + +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"aud_user_{user_id.hex[:8]}", + email=f"aud_{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 audit 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": "ya29.audit_tok", "refresh_token": "1//audit_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 + + +async def _get_recent_audit_rows(session, user_id, event_type: Optional[str] = None, limit: int = 10): + """Fetch recent AuditLog rows for a user, optionally filtered by event_type.""" + from db.models import AuditLog + from sqlalchemy import desc + + q = select(AuditLog).where(AuditLog.user_id == user_id).order_by(desc(AuditLog.id)).limit(limit) + if event_type: + q = q.where(AuditLog.event_type == event_type) + result = await session.execute(q) + return result.scalars().all() + + +def _assert_metadata_no_secrets(metadata: dict, context: str): + """Assert that an audit metadata dict contains no credential or secret fields.""" + if metadata is None: + return + forbidden_keys = { + "access_token", "refresh_token", "credentials_enc", + "client_secret", "client_id", "password", + } + for key in forbidden_keys: + assert key not in metadata, ( + f"Audit metadata must not contain '{key}' ({context}) — T-13-02" + ) + + # Also check nested values as strings + metadata_str = str(metadata) + for token_prefix in ("ya29.", "1//", "Bearer "): + assert token_prefix not in metadata_str, ( + f"Audit metadata must not contain raw token value starting with '{token_prefix}' " + f"({context}) — T-13-02" + ) + + +# ── T-13-05: Reconnect audit row ────────────────────────────────────────────── + + +async def test_reconnect_writes_metadata_only_audit_row(async_client, db_session): + """POST reconnect writes an audit row with event_type='cloud.reconnect'. + + T-13-05: Successful reconnect must be audited. The metadata_ column must + contain only the connection_id and provider — no tokens or credentials. + + FAILS: Phase 13 audit write does not exist yet. + """ + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED") + + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/reconnect", + headers=auth["headers"], + json={}, + ) + assert resp.status_code in (200, 202), ( + f"Expected 200/202 for reconnect, got {resp.status_code}: {resp.text}" + ) + + # Exactly one reconnect audit row must exist for this user + rows = await _get_recent_audit_rows(db_session, auth["user"].id, event_type="cloud.reconnect") + assert len(rows) >= 1, ( + "Reconnect must write an audit row with event_type='cloud.reconnect' (T-13-05)" + ) + + row = rows[0] + assert row.resource_id == conn.id, ( + "Audit row resource_id must be the connection UUID" + ) + _assert_metadata_no_secrets(row.metadata_, "cloud.reconnect audit") + + # Required metadata fields: connection_id and provider at minimum + assert row.metadata_ is not None, "Reconnect audit row must have metadata_" + assert "provider" in row.metadata_, ( + "Reconnect audit metadata must include 'provider' field" + ) + + +# ── T-13-05: Open file audit row ────────────────────────────────────────────── + + +async def test_open_file_writes_metadata_only_audit_row(async_client, db_session): + """GET /items/{id}/open writes an audit row with event_type='cloud.file_opened'. + + T-13-05: File open must be audited with metadata only — no bytes, no provider + URL, no tokens. Metadata must include connection_id, item_id (DocuVault UUID), + and provider_item_id (opaque). + + FAILS: Phase 13 audit write 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"], + ) + # Route may not exist yet (404 expected), but audit test structure is defined + assert resp.status_code in (200, 404), ( + f"Unexpected status: {resp.status_code}" + ) + if resp.status_code == 200: + rows = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.file_opened" + ) + assert len(rows) >= 1, ( + "File open must write an audit row with event_type='cloud.file_opened'" + ) + _assert_metadata_no_secrets(rows[0].metadata_, "cloud.file_opened audit") + + +# ── T-13-05: Upload audit row ────────────────────────────────────────────────── + + +async def test_upload_success_writes_metadata_only_audit_row(async_client, db_session): + """POST upload writes audit row 'cloud.file_uploaded' with metadata only. + + T-13-05: Upload must be audited. Metadata_ must include filename, size_bytes, + connection_id, and provider_item_id — never the raw file bytes or provider token. + + FAILS: Phase 13 audit write 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 content", "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, 201, 404, 409), ( + f"Unexpected status: {resp.status_code}" + ) + if resp.status_code in (200, 201): + rows = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.file_uploaded" + ) + assert len(rows) >= 1, ( + "Successful upload must write audit row 'cloud.file_uploaded' (T-13-05)" + ) + row = rows[0] + _assert_metadata_no_secrets(row.metadata_, "cloud.file_uploaded audit") + + # Must include metadata about what was uploaded + assert row.metadata_ is not None + assert "filename" in row.metadata_ or "name" in row.metadata_, ( + "Upload audit row must include filename or name in metadata_" + ) + # Must NOT include raw file bytes + assert "file_bytes" not in str(row.metadata_), ( + "Upload audit metadata must never include raw file bytes (T-13-02)" + ) + + +async def test_upload_conflict_does_not_write_false_overwrite_audit(async_client, db_session): + """Upload conflict does NOT write a false 'cloud.file_uploaded' audit row. + + T-13-05: Audit trail accuracy. A conflict that was not resolved must not + produce a 'cloud.file_uploaded' event — that would be a false audit record. + + FAILS: Phase 13 audit write does not exist yet. + """ + from db.models import AuditLog + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id) + + files = {"file": ("duplicate.pdf", b"%PDF-1.4 duplicate", "application/pdf")} + data = {"parent_ref": "root", "filename": "duplicate.pdf"} + + # Count audit rows before the upload + before = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.file_uploaded" + ) + before_count = len(before) + + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/items/upload", + headers=auth["headers"], + files=files, + data=data, + ) + # Conflict response expected + if resp.status_code == 409 or (resp.status_code == 200 and resp.json().get("kind") == "conflict"): + after = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.file_uploaded" + ) + assert len(after) == before_count, ( + "A conflict that was not resolved must not write a false 'cloud.file_uploaded' " + "audit event (T-13-05)" + ) + + +# ── T-13-05: Create folder audit row ───────────────────────────────────────── + + +async def test_create_folder_writes_metadata_only_audit_row(async_client, db_session): + """POST create-folder writes audit row 'cloud.folder_created' with metadata only. + + T-13-05: Folder creation must be audited. Metadata must include connection_id, + name, parent_ref, and provider_item_id — never provider tokens. + + FAILS: Phase 13 audit write 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": "My New Folder"} + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/folders", + headers=auth["headers"], + json=payload, + ) + assert resp.status_code in (201, 404), ( + f"Unexpected status: {resp.status_code}" + ) + if resp.status_code == 201: + rows = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.folder_created" + ) + assert len(rows) >= 1, ( + "Create folder must write audit row 'cloud.folder_created' (T-13-05)" + ) + _assert_metadata_no_secrets(rows[0].metadata_, "cloud.folder_created audit") + assert rows[0].metadata_ is not None + assert "name" in rows[0].metadata_, ( + "Folder creation audit metadata must include 'name'" + ) + + +# ── T-13-05: Rename audit row ───────────────────────────────────────────────── + + +async def test_rename_success_writes_audit_row(async_client, db_session): + """Successful rename writes audit row 'cloud.item_renamed' (T-13-05). + + Metadata must include connection_id, old_name, new_name — never token values. + + FAILS: Phase 13 audit write 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 in (200, 404), ( + f"Unexpected status: {resp.status_code}" + ) + if resp.status_code == 200: + rows = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.item_renamed" + ) + assert len(rows) >= 1, ( + "Successful rename must write audit row 'cloud.item_renamed' (T-13-05)" + ) + _assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_renamed audit") + assert rows[0].metadata_ is not None + assert "new_name" in rows[0].metadata_, ( + "Rename audit metadata must include 'new_name'" + ) + + +async def test_rename_stale_does_not_write_false_rename_audit(async_client, db_session): + """Stale rename must NOT write a false 'cloud.item_renamed' audit event (T-13-05). + + If the rename was rejected due to stale metadata, no renamed event should be logged. + + FAILS: Phase 13 audit write does not exist yet. + """ + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id) + + before = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.item_renamed" + ) + before_count = len(before) + + payload = {"new_name": "Report.pdf", "etag": "stale-etag-xyz"} + resp = await async_client.patch( + f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename", + headers=auth["headers"], + json=payload, + ) + if resp.status_code == 409 or ( + resp.status_code == 200 and resp.json().get("kind") == "stale" + ): + after = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.item_renamed" + ) + assert len(after) == before_count, ( + "A rejected (stale) rename must not write a false 'cloud.item_renamed' audit event" + ) + + +# ── T-13-05: Move audit row ─────────────────────────────────────────────────── + + +async def test_move_success_writes_audit_row(async_client, db_session): + """Successful move writes audit row 'cloud.item_moved' with metadata only (T-13-05). + + Metadata must include connection_id, old_parent_ref, new_parent_ref — no tokens. + + FAILS: Phase 13 audit write 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 in (200, 404), ( + f"Unexpected status: {resp.status_code}" + ) + if resp.status_code == 200: + rows = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.item_moved" + ) + assert len(rows) >= 1, ( + "Successful move must write audit row 'cloud.item_moved' (T-13-05)" + ) + _assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_moved audit") + + +# ── T-13-05: Delete audit row ───────────────────────────────────────────────── + + +async def test_delete_success_writes_audit_row(async_client, db_session): + """Successful delete writes audit row 'cloud.item_deleted' with metadata only (T-13-05). + + Metadata must include connection_id, item_id, delete_kind ('trashed' or 'permanent'). + + FAILS: Phase 13 audit write 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, 404), ( + f"Unexpected status: {resp.status_code}" + ) + if resp.status_code in (200, 204): + rows = await _get_recent_audit_rows( + db_session, auth["user"].id, event_type="cloud.item_deleted" + ) + assert len(rows) >= 1, ( + "Successful delete must write audit row 'cloud.item_deleted' (T-13-05)" + ) + _assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_deleted audit") + if rows[0].metadata_: + assert "delete_kind" in rows[0].metadata_ or "reason" in rows[0].metadata_, ( + "Delete audit metadata must indicate 'trashed' vs 'permanent' (D-11)" + ) + + +# ── T-13-02: Admin audit log must not expose cloud credentials ───────────────── + + +async def test_admin_audit_log_never_exposes_cloud_credentials(async_client, db_session): + """Admin audit log viewer must not expose any cloud credentials in metadata_. + + T-13-02: Even if an audit row was written (legitimately or by a bug), the + admin audit log viewer endpoint must not surface access_token, refresh_token, + credentials_enc, or client_secret in any returned audit item. + + Seeds a crafted audit row that contains sensitive fields in metadata_ to + verify the API scrubs or rejects it. + """ + from services.audit import write_audit_log + + auth_admin = await _create_user_and_token(db_session, role="admin") + auth_user = await _create_user_and_token(db_session, role="user") + + # Deliberately inject a "bad" audit row that contains sensitive fields + # (simulating a bug in a Phase 13 implementation that leaks tokens) + await write_audit_log( + session=db_session, + event_type="cloud.reconnect", + user_id=auth_user["user"].id, + actor_id=auth_user["user"].id, + resource_id=None, + ip_address=None, + metadata_={ + "provider": "google_drive", + "access_token": "ya29.SHOULD_NOT_APPEAR", # should be scrubbed + "refresh_token": "1//SHOULD_NOT_APPEAR", # should be scrubbed + }, + ) + await db_session.commit() + + resp = await async_client.get( + "/api/admin/audit-log", + headers=auth_admin["headers"], + ) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + + body_text = resp.text + for forbidden in ("ya29.SHOULD_NOT_APPEAR", "1//SHOULD_NOT_APPEAR", "access_token", "refresh_token"): + assert forbidden not in body_text, ( + f"Admin audit log must not expose '{forbidden}' in response (T-13-02)" + ) + + +# ── T-13-02: Audit metadata must never contain provider bytes ───────────────── + + +async def test_audit_metadata_never_contains_binary_content(async_client, db_session): + """Audit rows for open/preview must never include raw provider bytes. + + T-13-02: Provider-owned bytes must never enter audit payloads, logs, or + broker payloads. This test asserts that post-operation audit metadata + cannot contain binary content (base64-encoded or raw). + + FAILS: Phase 13 audit write does not exist yet. + """ + import base64 + from db.models import AuditLog + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id) + + # Make an open request + resp = await async_client.get( + f"/api/cloud/connections/{conn.id}/items/fake_item_id/open", + headers=auth["headers"], + ) + # Regardless of status, check any audit rows written + rows = await _get_recent_audit_rows(db_session, auth["user"].id) + for row in rows: + if row.metadata_ is None: + continue + meta_str = str(row.metadata_) + # Check for base64-encoded binary content (> 200 chars of base64 alphabet) + import re + b64_like = re.findall(r"[A-Za-z0-9+/=]{200,}", meta_str) + assert len(b64_like) == 0, ( + f"Audit row {row.id} appears to contain base64-encoded binary content — " + f"raw bytes must never enter audit payloads (T-13-02)" + ) diff --git a/backend/tests/test_cloud_mutations.py b/backend/tests/test_cloud_mutations.py new file mode 100644 index 0000000..5b35435 --- /dev/null +++ b/backend/tests/test_cloud_mutations.py @@ -0,0 +1,653 @@ +""" +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}" + ) diff --git a/backend/tests/test_cloud_reconnect.py b/backend/tests/test_cloud_reconnect.py new file mode 100644 index 0000000..d2c0d39 --- /dev/null +++ b/backend/tests/test_cloud_reconnect.py @@ -0,0 +1,561 @@ +""" +Phase 13 Plan 01 — TDD RED: Reconnect, health, cache invalidation, and credential-refresh persistence contracts. + +Covers D-12 through D-16 and CONN-01 through CONN-03: + - D-12: Connection health visible in cloud browser and Settings (compact + full). + - D-13: Automatic health re-evaluation after credential-related failures. + Explicit Test action available. No probing on every folder navigation. + - D-14: Successful reconnect keeps stale metadata visible, invalidates provider/ + listing/capability caches, immediately refreshes current folder. + - D-15: Transient outage: preserve credentials and cached metadata, show + actionable warning, allow retry/reconnect; never delete data on timeout. + - D-16: Explicit disconnect confirmation: removes credentials and connection-scoped + cloud metadata; leaves provider files untouched. + - CONN-01: reconnect patches the existing CloudConnection row in-place (no new row). + - CONN-02: refreshed access_token and refresh_token are encrypted and persisted. + - CONN-03: a reconnect response must never expose raw credentials or provider URLs. + +All tests FAIL against the current codebase because Phase 13 reconnect and +health routes do not yet exist. +""" +from __future__ import annotations + +import uuid as _uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy import select + +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"rc_user_{user_id.hex[:8]}", + email=f"rc_{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 reconnect 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": "old_tok", "refresh_token": "old_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 + + +# ── CONN-01: Reconnect patches the existing row — no new row created ────────── + + +async def test_reconnect_patches_existing_row(async_client, db_session): + """POST /api/cloud/connections/{id}/reconnect patches the existing CloudConnection row. + + CONN-01: reconnect must update the existing row in-place. Creating a new row + would break item identity: all CloudItems reference the connection by UUID. + A new row would orphan every cached item. + + FAILS: Phase 13 route does not exist yet. + """ + from db.models import CloudConnection + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED") + + original_conn_id = conn.id + initial_count = ( + await db_session.execute( + select(CloudConnection).where(CloudConnection.user_id == auth["user"].id) + ) + ).scalars().all() + + payload = {"provider": "google_drive"} + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/reconnect", + headers=auth["headers"], + json=payload, + ) + assert resp.status_code in (200, 202), ( + f"Expected 200/202 for reconnect, got {resp.status_code}: {resp.text}" + ) + + # The row count must not increase — no new connection row created + after_count = ( + await db_session.execute( + select(CloudConnection).where(CloudConnection.user_id == auth["user"].id) + ) + ).scalars().all() + assert len(after_count) == len(initial_count), ( + "Reconnect must patch the existing row — no new CloudConnection row should be created (CONN-01)" + ) + + # The UUID must be unchanged + await db_session.refresh(conn) + assert conn.id == original_conn_id, ( + "CloudConnection UUID must not change on reconnect (CONN-01)" + ) + + +async def test_reconnect_foreign_user_blocked(async_client, db_session): + """User2 cannot reconnect 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.post( + f"/api/cloud/connections/{conn.id}/reconnect", + headers=auth2["headers"], + json={}, + ) + assert resp.status_code == 404, ( + f"Expected 404 IDOR block, got {resp.status_code}" + ) + + +# ── CONN-02: Refreshed credentials are encrypted and persisted ───────────────── + + +async def test_reconnect_persists_refreshed_credentials(async_client, db_session): + """Successful reconnect encrypts and persists refreshed access_token and refresh_token. + + CONN-02: After a successful reconnect (e.g. OneDrive token refresh), the new + credentials must be encrypted (same HKDF AES-256-GCM pattern) and stored in + credentials_enc. The old credentials_enc must be replaced. + + FAILS: Phase 13 route does not exist yet. + """ + from db.models import CloudConnection + from storage.cloud_utils import decrypt_credentials + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED") + + old_creds_enc = conn.credentials_enc + + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/reconnect", + headers=auth["headers"], + json={}, + ) + assert resp.status_code in (200, 202), ( + f"Expected 200/202 for reconnect, got {resp.status_code}: {resp.text}" + ) + + await db_session.refresh(conn) + new_creds_enc = conn.credentials_enc + + # Credentials_enc must be updated (refreshed token stored) + assert new_creds_enc != old_creds_enc, ( + "Reconnect must update credentials_enc with refreshed token (CONN-02)" + ) + + +async def test_reconnect_refreshed_credentials_are_encrypted(async_client, db_session): + """The credentials_enc stored after reconnect must be encrypted — no plaintext tokens. + + CONN-02: The ciphertext must not contain plaintext field names like 'access_token' + or raw token values. + + 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="AUTH_FAILED") + + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/reconnect", + headers=auth["headers"], + json={}, + ) + assert resp.status_code in (200, 202), ( + f"Expected 200/202 for reconnect, got {resp.status_code}" + ) + + await db_session.refresh(conn) + creds_enc = conn.credentials_enc + # Encrypted blob must not contain plaintext credential keys + assert "access_token" not in creds_enc, ( + "credentials_enc must not contain plaintext 'access_token' after reconnect" + ) + assert "refresh_token" not in creds_enc, ( + "credentials_enc must not contain plaintext 'refresh_token' after reconnect" + ) + + +# ── CONN-03: Reconnect response excludes raw credentials ───────────────────── + + +async def test_reconnect_response_excludes_credentials(async_client, db_session): + """Reconnect response must not contain raw credentials, provider URLs, or tokens. + + CONN-03: The reconnect response must be credential-free. No access_token, + refresh_token, client_secret, or provider-specific URL should appear. + + 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.post( + f"/api/cloud/connections/{conn.id}/reconnect", + headers=auth["headers"], + json={}, + ) + for forbidden in ( + "access_token", "refresh_token", "credentials_enc", + "client_secret", "client_id", + ): + assert forbidden not in resp.text, ( + f"Reconnect response must not expose '{forbidden}' (CONN-03)" + ) + + +# ── D-12: Connection health endpoint (compact + full) ───────────────────────── + + +async def test_connection_health_endpoint_returns_status(async_client, db_session): + """GET /api/cloud/connections/{id}/health returns connection health status. + + D-12: Connection health must be available explicitly (not inferred from browse). + Response must include a 'status' field: 'healthy' | 'degraded' | 'auth_failed' | 'offline'. + + 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}/health", + headers=auth["headers"], + ) + assert resp.status_code == 200, ( + f"Expected 200 for health check, got {resp.status_code}: {resp.text}" + ) + body = resp.json() + assert "status" in body, "Health response must include 'status' field (D-12)" + assert body["status"] in ("healthy", "degraded", "auth_failed", "offline"), ( + f"Health status must be a known value, got {body.get('status')!r}" + ) + + +async def test_connection_health_foreign_user_blocked(async_client, db_session): + """User2 cannot check health of User1's connection — IDOR protection. + + 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}/health", + headers=auth2["headers"], + ) + assert resp.status_code == 404, ( + f"Expected 404 IDOR block, got {resp.status_code}" + ) + + +async def test_connection_health_excludes_credentials(async_client, db_session): + """Health endpoint response must not expose any credentials or tokens (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.get( + f"/api/cloud/connections/{conn.id}/health", + headers=auth["headers"], + ) + for forbidden in ("access_token", "refresh_token", "credentials_enc", "client_secret"): + assert forbidden not in resp.text, ( + f"Health response must not expose '{forbidden}' (T-13-02)" + ) + + +# ── D-13: Explicit Test action and no probe on navigation ───────────────────── + + +async def test_connection_test_action_available(async_client, db_session): + """POST /api/cloud/connections/{id}/test triggers explicit connection test. + + D-13: Explicit Test action is available. It must run a health probe and + update the connection status row without modifying provider content. + + 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.post( + f"/api/cloud/connections/{conn.id}/test", + headers=auth["headers"], + ) + assert resp.status_code in (200, 202), ( + f"Expected 200/202 for connection test, got {resp.status_code}: {resp.text}" + ) + body = resp.json() + assert "status" in body, "Test response must include 'status' field (D-13)" + + +# ── D-14: Reconnect preserves stale metadata and invalidates caches ─────────── + + +async def test_reconnect_preserves_cached_metadata_as_stale(async_client, db_session): + """Successful reconnect marks cached items as stale, not deleted. + + D-14: After reconnect, cached metadata remains visible to the user as stale + while DocuVault revalidates. The provider listing is re-fetched immediately. + CloudItems must NOT be deleted on reconnect. + + FAILS: Phase 13 route does not exist yet. + """ + from db.models import CloudItem, CloudFolderState + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED") + + # Seed a cached cloud item + item = CloudItem( + id=_uuid.uuid4(), + user_id=auth["user"].id, + connection_id=conn.id, + provider_item_id="gdrive_file_abc", + name="Important Report.pdf", + kind="file", + ) + db_session.add(item) + await db_session.commit() + + resp = await async_client.post( + f"/api/cloud/connections/{conn.id}/reconnect", + headers=auth["headers"], + json={}, + ) + assert resp.status_code in (200, 202), ( + f"Expected 200/202 for reconnect, got {resp.status_code}" + ) + + # The cached item must still exist after reconnect + result = await db_session.execute( + select(CloudItem).where( + CloudItem.id == item.id, + CloudItem.deleted_at.is_(None), + ) + ) + surviving_item = result.scalar_one_or_none() + assert surviving_item is not None, ( + "Cached cloud items must NOT be deleted on reconnect — they become stale (D-14)" + ) + + +# ── D-15: Transient outage preserves credentials and cached metadata ────────── + + +async def test_transient_outage_preserves_credentials(async_client, db_session): + """A transient provider timeout must not delete credentials or cached metadata. + + D-15: Timeout or temporarily unreachable provider = unhealthy state only. + Credentials must remain in credentials_enc. CloudItems must not be deleted. + + FAILS: Phase 13 route does not exist yet. + """ + from db.models import CloudItem + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id, status="ACTIVE") + + old_creds_enc = conn.credentials_enc + + # Seed a cached item + item = CloudItem( + id=_uuid.uuid4(), + user_id=auth["user"].id, + connection_id=conn.id, + provider_item_id="gdrive_file_xyz", + name="Budget.xlsx", + kind="file", + ) + db_session.add(item) + await db_session.commit() + + # Simulate a folder browse that encounters a transient timeout + # The browse endpoint must gracefully handle timeout, not destroy data + resp = await async_client.get( + f"/api/cloud/connections/{conn.id}/items", + headers=auth["headers"], + ) + # Even a failed/degraded browse must not destroy credentials + await db_session.refresh(conn) + assert conn.credentials_enc == old_creds_enc, ( + "Transient provider outage must not erase credentials_enc (D-15)" + ) + + # Cached items must survive + result = await db_session.execute( + select(CloudItem).where( + CloudItem.id == item.id, + CloudItem.deleted_at.is_(None), + ) + ) + surviving = result.scalar_one_or_none() + assert surviving is not None, ( + "Transient provider outage must not delete cached metadata (D-15)" + ) + + +# ── D-16: Disconnect removes credentials and connection-scoped metadata ──────── + + +async def test_disconnect_removes_credentials_enc(async_client, db_session): + """DELETE /api/cloud/connections/{id} removes credentials_enc from the database. + + D-16: Explicit user-initiated disconnect requires confirmation, removes credentials + and connection-scoped cloud metadata, and leaves provider files untouched. + + FAILS: Phase 13 route does not exist yet — current disconnect tested in test_cloud.py. + This test asserts the Phase 13 enhanced disconnect semantics. + """ + from db.models import CloudConnection + + 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}", + headers=auth["headers"], + ) + assert resp.status_code in (200, 204), ( + f"Expected 200/204 for disconnect, got {resp.status_code}: {resp.text}" + ) + + # Connection row must be deleted or credentials must be cleared + result = await db_session.execute( + select(CloudConnection).where(CloudConnection.id == conn.id) + ) + conn_after = result.scalar_one_or_none() + if conn_after is not None: + # If row persists, credentials_enc must be nulled out + assert not conn_after.credentials_enc, ( + "Disconnect must remove credentials_enc (D-16)" + ) + + +async def test_disconnect_removes_connection_scoped_cloud_items(async_client, db_session): + """Disconnect removes all connection-scoped CloudItems — no orphaned metadata. + + D-16: All connection-scoped metadata must be cleaned up on explicit disconnect. + Phase 13 has no byte cache, so this is metadata-only cleanup. + + FAILS: Phase 13 route does not exist yet. + """ + from db.models import CloudItem + + auth = await _create_user_and_token(db_session) + conn = await _create_cloud_connection(db_session, auth["user"].id) + + # Seed connection-scoped cloud items + for i in range(3): + item = CloudItem( + id=_uuid.uuid4(), + user_id=auth["user"].id, + connection_id=conn.id, + provider_item_id=f"item_{i}", + name=f"File {i}.txt", + kind="file", + ) + db_session.add(item) + await db_session.commit() + + resp = await async_client.delete( + f"/api/cloud/connections/{conn.id}", + headers=auth["headers"], + ) + assert resp.status_code in (200, 204), ( + f"Expected 200/204 for disconnect, got {resp.status_code}" + ) + + # All connection-scoped items must be removed (CASCADE or explicit cleanup) + result = await db_session.execute( + select(CloudItem).where( + CloudItem.connection_id == conn.id, + CloudItem.deleted_at.is_(None), + ) + ) + orphaned = result.scalars().all() + assert len(orphaned) == 0, ( + f"Disconnect must remove all connection-scoped CloudItems — " + f"found {len(orphaned)} orphaned items (D-16)" + ) + + +async def test_disconnect_foreign_user_blocked(async_client, db_session): + """User2 cannot disconnect 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.delete( + f"/api/cloud/connections/{conn.id}", + headers=auth2["headers"], + ) + assert resp.status_code == 404, ( + f"Expected 404 IDOR block, got {resp.status_code}" + )