Files
kite/backend/tests/test_cloud_reconnect.py
curo1305 efb596433c test(13-01): add red API and audit contracts for reconnect, content, and mutation flows
- 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)
2026-06-22 17:57:55 +02:00

562 lines
20 KiB
Python

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