""" 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}" )