feat(12-02): decompose api/cloud.py into package with connection-ID browse endpoint

- Add api/cloud/ package: connections.py, browse.py, schemas.py, __init__.py
- Add GET /api/cloud/connections/{connection_id}/items (T-12-01 IDOR, T-12-03 cred-free)
- Add PATCH /api/cloud/connections/{id} for display_name_override rename
- Add display_name_override ORM field to CloudConnection model
- Add CloudResourceAdapter service layer with str/UUID coercion
- Fix UUID type compatibility: test_cloud_items.py now uses UUID(as_uuid=True)
  matching conftest — removes String(36) patch that caused type incompatibility
- Add 11 Phase 12 integration tests (IDOR, admin block, credential exclusion,
  duplicate providers, rename, malformed UUID)
- Remove deleted api/cloud.py (replaced by api/cloud/ package)
This commit is contained in:
curo1305
2026-06-18 23:10:30 +02:00
parent ff33439f0a
commit e186019066
9 changed files with 850 additions and 182 deletions
+230
View File
@@ -851,3 +851,233 @@ async def test_oauth_initiate_requires_auth(async_client, db_session):
)
assert resp.status_code in (401, 403), \
f"Expected 401 or 403 for unauthenticated request, got {resp.status_code}"
# ── Phase 12: Connection-ID browse tests ──────────────────────────────────────
async def _create_cloud_connection(session, user_id, provider: str = "google_drive", name: str = "My Drive"):
"""Create a CloudConnection row for 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="ACTIVE",
)
session.add(conn)
await session.commit()
return conn
async def test_browse_connection_rejects_foreign_owner(async_client, db_session):
"""GET /api/cloud/connections/{id}/items rejects access by a non-owner (T-12-01).
User2 cannot browse user1's connection — returns 404 (IDOR protection).
"""
auth1 = await _create_user_and_token(db_session, role="user")
auth2 = await _create_user_and_token(db_session, role="user")
conn = await _create_cloud_connection(db_session, auth1["user"].id)
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items",
headers=auth2["headers"],
)
assert resp.status_code == 404, f"Expected 404 IDOR block, got {resp.status_code}"
async def test_browse_connection_admin_blocked(async_client, db_session):
"""GET /api/cloud/connections/{id}/items rejects admin tokens (get_regular_user guard)."""
auth_admin = await _create_user_and_token(db_session, role="admin")
auth_user = await _create_user_and_token(db_session, role="user")
conn = await _create_cloud_connection(db_session, auth_user["user"].id)
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items",
headers=auth_admin["headers"],
)
# Admin tokens are blocked by get_regular_user — 403 or 404
assert resp.status_code in (403, 404), f"Expected 403/404 for admin, got {resp.status_code}"
async def test_browse_connection_response_excludes_credentials(async_client, db_session, monkeypatch):
"""Browse response never includes credentials_enc or decrypted credential fields (T-12-03)."""
from unittest.mock import AsyncMock, patch
from storage.cloud_base import CloudListing, CloudCapability, STATE_SUPPORTED
auth = await _create_user_and_token(db_session, role="user")
conn = await _create_cloud_connection(db_session, auth["user"].id)
# Mock adapter so no real provider call is made
mock_adapter = AsyncMock()
mock_adapter.list_folder = AsyncMock(return_value=CloudListing(items=(), complete=True))
mock_adapter.get_capabilities = AsyncMock(return_value={
action: CloudCapability(action=action, state=STATE_SUPPORTED)
for action in ["browse"]
})
from storage.cloud_base import ACTIONS, STATE_UNSUPPORTED, REASON_PROVIDER_UNSUPPORTED
caps = {
action: CloudCapability(
action=action,
state=STATE_SUPPORTED if action == "browse" else STATE_UNSUPPORTED,
reason=None if action == "browse" else REASON_PROVIDER_UNSUPPORTED,
message=None if action == "browse" else "Not available.",
)
for action in ACTIONS
}
mock_adapter.get_capabilities = AsyncMock(return_value=caps)
with patch("api.cloud.browse.build_cloud_resource_adapter", return_value=mock_adapter):
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items",
headers=auth["headers"],
)
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
data = resp.json()
# T-12-03: credential fields must not be in response
assert "credentials_enc" not in data
assert "access_token" not in str(data)
assert "refresh_token" not in str(data)
assert "password" not in str(data)
# Response schema validation
assert "connection_id" in data
assert "items" in data
assert "capabilities" in data
assert "freshness" in data
async def test_browse_two_google_drive_connections_independently(async_client, db_session):
"""Two Google Drive connections for one user are independently browsable (D-05)."""
from unittest.mock import AsyncMock, patch
from storage.cloud_base import CloudListing, CloudCapability, ACTIONS, STATE_SUPPORTED, STATE_UNSUPPORTED, REASON_PROVIDER_UNSUPPORTED
auth = await _create_user_and_token(db_session, role="user")
conn1 = await _create_cloud_connection(db_session, auth["user"].id, provider="google_drive", name="Drive 1")
conn2 = await _create_cloud_connection(db_session, auth["user"].id, provider="google_drive", name="Drive 2")
caps = {
action: CloudCapability(
action=action,
state=STATE_SUPPORTED if action == "browse" else STATE_UNSUPPORTED,
reason=None if action == "browse" else REASON_PROVIDER_UNSUPPORTED,
message=None if action == "browse" else "Not available.",
)
for action in ACTIONS
}
mock_adapter = AsyncMock()
mock_adapter.list_folder = AsyncMock(return_value=CloudListing(items=(), complete=True))
mock_adapter.get_capabilities = AsyncMock(return_value=caps)
with patch("api.cloud.browse.build_cloud_resource_adapter", return_value=mock_adapter):
resp1 = await async_client.get(f"/api/cloud/connections/{conn1.id}/items", headers=auth["headers"])
resp2 = await async_client.get(f"/api/cloud/connections/{conn2.id}/items", headers=auth["headers"])
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json()["connection_id"] == str(conn1.id)
assert resp2.json()["connection_id"] == str(conn2.id)
async def test_rename_connection_display_name(async_client, db_session):
"""PATCH /api/cloud/connections/{id} renames display_name only (mass-assignment prevention)."""
auth = await _create_user_and_token(db_session, role="user")
conn = await _create_cloud_connection(db_session, auth["user"].id, name="Original Name")
resp = await async_client.patch(
f"/api/cloud/connections/{conn.id}",
json={"display_name": "My Work Drive"},
headers=auth["headers"],
)
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
data = resp.json()
assert data["display_name"] == "My Work Drive"
async def test_rename_connection_rejects_foreign_owner(async_client, db_session):
"""PATCH /api/cloud/connections/{id} rejects non-owner (T-12-01 IDOR)."""
auth1 = await _create_user_and_token(db_session, role="user")
auth2 = await _create_user_and_token(db_session, role="user")
conn = await _create_cloud_connection(db_session, auth1["user"].id, name="Private Drive")
resp = await async_client.patch(
f"/api/cloud/connections/{conn.id}",
json={"display_name": "Hacked"},
headers=auth2["headers"],
)
assert resp.status_code == 404
async def test_rename_connection_rejects_blank_name(async_client, db_session):
"""PATCH /api/cloud/connections/{id} rejects blank display_name."""
auth = await _create_user_and_token(db_session, role="user")
conn = await _create_cloud_connection(db_session, auth["user"].id)
resp = await async_client.patch(
f"/api/cloud/connections/{conn.id}",
json={"display_name": " "},
headers=auth["headers"],
)
assert resp.status_code == 422
async def test_rename_connection_rejects_mass_assignment(async_client, db_session):
"""PATCH /api/cloud/connections/{id} ignores unknown fields (mass-assignment prevention)."""
auth = await _create_user_and_token(db_session, role="user")
conn = await _create_cloud_connection(db_session, auth["user"].id)
resp = await async_client.patch(
f"/api/cloud/connections/{conn.id}",
json={"display_name": "OK Name", "credentials_enc": "HACKED", "provider": "evil"},
headers=auth["headers"],
)
# Should succeed but only update display_name
assert resp.status_code == 200
data = resp.json()
# credentials_enc must not appear in response
assert "credentials_enc" not in data
# Verify provider was not changed in DB
from db.models import CloudConnection as CC
from sqlalchemy import select
result = await db_session.execute(select(CC).where(CC.id == conn.id))
updated = result.scalar_one()
assert updated.provider == "google_drive" # unchanged
async def test_browse_connection_malformed_uuid(async_client, db_session):
"""GET /api/cloud/connections/{bad-uuid}/items returns 422 for malformed UUID."""
auth = await _create_user_and_token(db_session, role="user")
resp = await async_client.get(
"/api/cloud/connections/not-a-valid-uuid/items",
headers=auth["headers"],
)
assert resp.status_code == 422
async def test_list_connections_returns_all_providers(async_client, db_session):
"""GET /api/cloud/connections returns all connections including duplicate providers."""
auth = await _create_user_and_token(db_session, role="user")
conn1 = await _create_cloud_connection(db_session, auth["user"].id, provider="google_drive", name="Drive A")
conn2 = await _create_cloud_connection(db_session, auth["user"].id, provider="google_drive", name="Drive B")
resp = await async_client.get("/api/cloud/connections", headers=auth["headers"])
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) >= 2
ids = {item["id"] for item in items}
assert str(conn1.id) in ids
assert str(conn2.id) in ids
# credentials_enc must not be in any item
for item in items:
assert "credentials_enc" not in item