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:
@@ -25,10 +25,10 @@ from typing import Optional
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import String, Text, event
|
||||
from sqlalchemy import Text, event
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.dialects.postgresql import UUID, INET, JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from db.models import Base, CloudItem, CloudItemTopic, CloudFolderState, CloudConnection, Topic, User, Quota
|
||||
from storage.cloud_base import CloudListing, CloudResource, CloudCapability, STATE_SUPPORTED
|
||||
@@ -48,69 +48,80 @@ from services.cloud_items import (
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
"""In-memory SQLite session with PostgreSQL-type shims."""
|
||||
"""In-memory SQLite session with PostgreSQL-type shims.
|
||||
|
||||
Patches INET/JSONB to Text for SQLite compatibility.
|
||||
UUID(as_uuid=True) is left as-is: SQLAlchemy renders it as CHAR(32) and
|
||||
the bind processor handles uuid.UUID ↔ 32-char hex automatically.
|
||||
"""
|
||||
from sqlalchemy.dialects.sqlite.base import SQLiteTypeCompiler
|
||||
from sqlalchemy.dialects.postgresql import INET, JSONB
|
||||
|
||||
_orig_visit_INET = getattr(SQLiteTypeCompiler, "visit_INET", None)
|
||||
_orig_visit_JSONB = getattr(SQLiteTypeCompiler, "visit_JSONB", None)
|
||||
|
||||
def _visit_inet(self, type_, **kw):
|
||||
return "TEXT"
|
||||
|
||||
def _visit_jsonb(self, type_, **kw):
|
||||
return "TEXT"
|
||||
|
||||
SQLiteTypeCompiler.visit_INET = _visit_inet # type: ignore[attr-defined]
|
||||
SQLiteTypeCompiler.visit_JSONB = _visit_jsonb # type: ignore[attr-defined]
|
||||
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
# Shim PostgreSQL types to SQLite-compatible equivalents
|
||||
from sqlalchemy import event as sa_event
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@sa_event.listens_for(engine.sync_engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
# Patch dialect-specific column types before table creation
|
||||
import sqlalchemy.dialects.postgresql as pg
|
||||
_orig_uuid_init = pg.UUID.__init__
|
||||
|
||||
def _patch_columns(metadata):
|
||||
for table in metadata.tables.values():
|
||||
for col in table.columns:
|
||||
if isinstance(col.type, pg.UUID):
|
||||
col.type = String(36)
|
||||
elif isinstance(col.type, pg.INET):
|
||||
col.type = String(45)
|
||||
elif isinstance(col.type, pg.JSONB):
|
||||
col.type = Text()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
_patch_columns(Base.metadata)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
finally:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
if _orig_visit_INET is not None:
|
||||
SQLiteTypeCompiler.visit_INET = _orig_visit_INET # type: ignore
|
||||
else:
|
||||
try:
|
||||
del SQLiteTypeCompiler.visit_INET # type: ignore
|
||||
except AttributeError:
|
||||
pass
|
||||
if _orig_visit_JSONB is not None:
|
||||
SQLiteTypeCompiler.visit_JSONB = _orig_visit_JSONB # type: ignore
|
||||
else:
|
||||
try:
|
||||
del SQLiteTypeCompiler.visit_JSONB # type: ignore
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _user_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
def _user_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
def _conn_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
def _conn_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
def _item_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
def _item_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
async def _make_user(session: AsyncSession, user_id: Optional[str] = None) -> str:
|
||||
async def _make_user(session: AsyncSession, user_id: Optional[uuid.UUID] = None) -> uuid.UUID:
|
||||
uid = user_id or _user_id()
|
||||
u = User(
|
||||
id=uid,
|
||||
handle=f"user_{uid[:8]}",
|
||||
email=f"{uid[:8]}@example.com",
|
||||
handle=f"user_{uid.hex[:8]}",
|
||||
email=f"{uid.hex[:8]}@example.com",
|
||||
password_hash="hash",
|
||||
role="user",
|
||||
)
|
||||
@@ -120,8 +131,8 @@ async def _make_user(session: AsyncSession, user_id: Optional[str] = None) -> st
|
||||
|
||||
|
||||
async def _make_connection(
|
||||
session: AsyncSession, user_id: str, conn_id: Optional[str] = None
|
||||
) -> str:
|
||||
session: AsyncSession, user_id: uuid.UUID, conn_id: Optional[uuid.UUID] = None
|
||||
) -> uuid.UUID:
|
||||
cid = conn_id or _conn_id()
|
||||
conn = CloudConnection(
|
||||
id=cid,
|
||||
@@ -137,8 +148,8 @@ async def _make_connection(
|
||||
|
||||
|
||||
def _cloud_resource(
|
||||
connection_id: str,
|
||||
user_id: str,
|
||||
connection_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
provider_item_id: str = "item-001",
|
||||
name: str = "test.pdf",
|
||||
kind: str = "file",
|
||||
@@ -149,8 +160,8 @@ def _cloud_resource(
|
||||
return CloudResource(
|
||||
id=uuid.uuid4(),
|
||||
provider_item_id=provider_item_id,
|
||||
connection_id=uuid.UUID(connection_id),
|
||||
user_id=uuid.UUID(user_id),
|
||||
connection_id=connection_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
kind=kind,
|
||||
parent_ref=parent_ref,
|
||||
@@ -359,7 +370,7 @@ async def test_service_resolve_owned_connection_found(db_session: AsyncSession):
|
||||
cid = await _make_connection(db_session, uid)
|
||||
|
||||
conn = await resolve_owned_connection(db_session, connection_id=cid, user_id=uid)
|
||||
assert str(conn.id) == cid
|
||||
assert conn.id == cid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user