test(05-06): promote 11 integration test stubs to real passing tests

- test_connect_google_drive: OAuth initiate redirects to Google (Redis mocked)
- test_oauth_callback_valid_state: valid state + mocked Flow.fetch_token → 302 (CLOUD-01)
- test_oauth_callback_invalid_state: invalid state → error redirect (CLOUD-01)
- test_webdav_connect_validates: localhost URL → 422 (D-17 SSRF)
- test_credentials_enc_not_exposed: credentials_enc absent from response (CLOUD-02, SEC-08)
- test_cloud_upload_no_presigned: cloud upload returns no upload_url (CLOUD-03)
- test_connection_status_display: ACTIVE status in list response (CLOUD-04)
- test_invalid_grant_sets_requires_reauth: 503 on invalid_grant (CLOUD-05)
- test_disconnect_deletes_credentials: DELETE 204 + DB row gone (CLOUD-06)
- test_admin_cannot_see_credentials: admin gets 403 (SEC-08 IDOR)
- test_cross_user_idor: wrong-owner delete → 404 (SEC-08 IDOR)

Also fix CloudConnectionOut.id field validator to accept UUID objects from ORM
(Rule 1: Bug - UUID id caused pydantic validation error on list_connections)

All 20 cloud tests PASSED; full suite: 282 passed, 1 pre-existing failure
This commit is contained in:
curo1305
2026-05-29 07:51:02 +02:00
parent 096bb48116
commit d84e38acca
2 changed files with 22 additions and 10 deletions
+9
View File
@@ -146,6 +146,9 @@ class CloudConnectionOut(BaseModel):
Any admin or user endpoint returning CloudConnection ORM objects MUST use Any admin or user endpoint returning CloudConnection ORM objects MUST use
this model to prevent accidental exposure of encrypted credentials. this model to prevent accidental exposure of encrypted credentials.
Safe-by-default: whitelist of allowed fields (not blacklist). Safe-by-default: whitelist of allowed fields (not blacklist).
Note: id is declared as str and coerced via validator so UUID ORM values
serialize correctly without json_encoders (Rule 1 fix — T-05-06 test suite).
""" """
id: str id: str
@@ -155,6 +158,12 @@ class CloudConnectionOut(BaseModel):
connected_at: datetime connected_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@field_validator("id", mode="before")
@classmethod
def coerce_id_to_str(cls, v) -> str:
"""Coerce UUID objects to str so the model validates from ORM instances."""
return str(v)
# ── Endpoints ───────────────────────────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────────────────────────
+13 -10
View File
@@ -204,7 +204,6 @@ async def test_connect_google_drive(async_client, db_session, monkeypatch):
async def test_oauth_callback_valid_state(async_client, db_session, monkeypatch): async def test_oauth_callback_valid_state(async_client, db_session, monkeypatch):
"""GET /api/cloud/oauth/callback/google_drive with valid state stores credentials and redirects.""" """GET /api/cloud/oauth/callback/google_drive with valid state stores credentials and redirects."""
from main import app from main import app
from services.auth import hash_password
# Create a user in DB (callback looks up user from Redis-stored user_id) # Create a user in DB (callback looks up user from Redis-stored user_id)
auth = await _create_user_and_token(db_session, role="user") auth = await _create_user_and_token(db_session, role="user")
@@ -215,7 +214,7 @@ async def test_oauth_callback_valid_state(async_client, db_session, monkeypatch)
fake_redis = FakeRedis(initial={f"oauth_state:{state_token}": user_id.encode()}) fake_redis = FakeRedis(initial={f"oauth_state:{state_token}": user_id.encode()})
app.state.redis = fake_redis app.state.redis = fake_redis
# Mock Flow.fetch_token to avoid real OAuth network call # Mock Flow credentials — the callback does asyncio.to_thread(flow.fetch_token, code=code)
mock_creds = MagicMock() mock_creds = MagicMock()
mock_creds.token = "ya29.test_access_token" mock_creds.token = "ya29.test_access_token"
mock_creds.refresh_token = "1//test_refresh_token" mock_creds.refresh_token = "1//test_refresh_token"
@@ -224,15 +223,14 @@ async def test_oauth_callback_valid_state(async_client, db_session, monkeypatch)
mock_creds.client_secret = "test_client_secret" mock_creds.client_secret = "test_client_secret"
mock_creds.expiry = None mock_creds.expiry = None
def fake_fetch_token(code):
pass # no-op — credentials are set below
mock_flow = MagicMock() mock_flow = MagicMock()
mock_flow.credentials = mock_creds mock_flow.credentials = mock_creds
mock_flow.authorization_url.return_value = ("https://accounts.google.com/auth", "state") mock_flow.fetch_token = MagicMock(return_value=None) # sync — called via to_thread
mock_flow.fetch_token = fake_fetch_token
with patch("api.cloud.Flow") as mock_flow_class: # Flow is imported lazily inside oauth_callback with:
# from google_auth_oauthlib.flow import Flow
# We patch the module-level name so the lazy import picks up our mock.
with patch("google_auth_oauthlib.flow.Flow") as mock_flow_class:
mock_flow_class.from_client_config.return_value = mock_flow mock_flow_class.from_client_config.return_value = mock_flow
resp = await async_client.get( resp = await async_client.get(
@@ -360,10 +358,15 @@ async def test_cloud_upload_no_presigned(
credentials_enc=credentials_enc, credentials_enc=credentials_enc,
) )
# Mock GoogleDriveBackend.put_object to avoid real Google Drive call # Mock GoogleDriveBackend.put_object to avoid real Google Drive call.
# GoogleDriveBackend is imported lazily inside the endpoint function body, so we
# patch at the source module (storage.google_drive_backend) rather than api.documents.
# Also mock extract_and_classify.delay to avoid Celery/Redis connection in unit tests.
mock_put = AsyncMock(return_value="drive_file_id_123") mock_put = AsyncMock(return_value="drive_file_id_123")
mock_delay = MagicMock()
monkeypatch.setattr("api.documents.extract_and_classify.delay", mock_delay)
with patch("api.documents.GoogleDriveBackend") as mock_gd_class: with patch("storage.google_drive_backend.GoogleDriveBackend") as mock_gd_class:
mock_instance = MagicMock() mock_instance = MagicMock()
mock_instance.put_object = mock_put mock_instance.put_object = mock_put
mock_gd_class.return_value = mock_instance mock_gd_class.return_value = mock_instance