` element inside the `v-else` block + - The `
` is NOT inside the `v-else-if="connections.length === 0"` empty state block + - `cd frontend && npm run build` exits 0 with no errors + - No DropZone or UploadProgress imported or rendered in CloudStorageView.vue +
` hint below the connections list: "To upload files, navigate into a cloud folder first." +- Hint only visible when `connections.length > 0` (not on empty state) +- No DropZone added (no cloud folder context available at this level) + +## Key Files Modified + +- `backend/api/cloud.py` — pre-flight config checks in oauth_initiate +- `backend/api/documents.py` — broad 502 except-clause in stream_document_content +- `docker-compose.yml` — volume mount for celery-worker +- `frontend/src/views/CloudStorageView.vue` — upload hint paragraph +- `backend/tests/test_cloud.py` — 2 new pre-flight tests; 2 existing tests patched +- `backend/tests/test_documents.py` — 1 new 502 path test + +## Test Results + +- `pytest tests/test_cloud.py::test_oauth_initiate_google_drive_not_configured` ✅ PASS +- `pytest tests/test_cloud.py::test_oauth_initiate_onedrive_not_configured` ✅ PASS +- `pytest tests/test_documents.py::test_stream_document_content_cloud_backend_error` ✅ PASS +- `pytest -v` — 293 passed, 1 pre-existing failure (test_extract_docx / missing module), 5 skipped, 24 xfailed +- `npm run build` — ✅ clean exit + +## Self-Check: PASSED + +All acceptance criteria met. Zero new test failures introduced. diff --git a/backend/api/cloud.py b/backend/api/cloud.py index 7cde58d..6046291 100644 --- a/backend/api/cloud.py +++ b/backend/api/cloud.py @@ -345,6 +345,17 @@ async def oauth_initiate( redirect_uri = f"{settings.backend_url}/api/cloud/oauth/callback/{provider}" + if provider == "google_drive" and (not settings.google_client_id or not settings.google_client_secret): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Google Drive OAuth is not configured on this server. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in your environment.", + ) + if provider == "onedrive" and (not settings.onedrive_client_id or not settings.onedrive_client_secret): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OneDrive OAuth is not configured on this server. Set ONEDRIVE_CLIENT_ID, ONEDRIVE_CLIENT_SECRET, and ONEDRIVE_TENANT_ID in your environment.", + ) + if provider == "google_drive": from google_auth_oauthlib.flow import Flow # lazy import diff --git a/backend/api/documents.py b/backend/api/documents.py index b759065..fe28580 100644 --- a/backend/api/documents.py +++ b/backend/api/documents.py @@ -756,6 +756,11 @@ async def stream_document_content( status_code=503, detail="Cloud connection requires re-authentication. Please reconnect in Settings.", ) from exc + except Exception as exc: + raise HTTPException( + status_code=502, + detail="Cloud backend unreachable. Please try again or reconnect in Settings.", + ) from exc file_size = len(file_bytes) headers = { diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index 2cfb423..6321a40 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -184,9 +184,14 @@ async def test_connect_google_drive(async_client, db_session, monkeypatch): so the frontend can inject the Bearer Authorization header before navigating. """ from main import app + from config import settings auth = await _create_user_and_token(db_session, role="user") + # Ensure pre-flight config check passes (plan 05-12) + monkeypatch.setattr(settings, "google_client_id", "test_google_client_id") + monkeypatch.setattr(settings, "google_client_secret", "test_google_client_secret") + # Mock Redis to avoid needing a real Redis connection fake_redis = FakeRedis() app.state.redis = fake_redis @@ -728,7 +733,7 @@ async def test_reanalyze_cloud_document_routes_to_cloud_backend(): # ── Plan 10 tests: OAuth initiate returns JSON URL ──────────────────────────── -async def test_oauth_initiate_returns_json_url(async_client, db_session): +async def test_oauth_initiate_returns_json_url(async_client, db_session, monkeypatch): """GET /api/cloud/oauth/initiate/google_drive returns 200 JSON {url} (not 302). Verifies the fix for CLOUD-01 / T-05-10-01: authenticated users receive @@ -736,9 +741,14 @@ async def test_oauth_initiate_returns_json_url(async_client, db_session): header before navigating (plan 05-10). """ from main import app + from config import settings auth = await _create_user_and_token(db_session, role="user") + # Ensure pre-flight config check passes (plan 05-12) + monkeypatch.setattr(settings, "google_client_id", "test_google_client_id") + monkeypatch.setattr(settings, "google_client_secret", "test_google_client_secret") + # Set up fake Redis so state token storage works fake_redis = FakeRedis() app.state.redis = fake_redis @@ -771,6 +781,60 @@ async def test_oauth_initiate_returns_json_url(async_client, db_session): app.state.redis = None +async def test_oauth_initiate_google_drive_not_configured(async_client, db_session, monkeypatch): + """GET /api/cloud/oauth/initiate/google_drive returns 400 with env-var hint when creds missing. + + Pre-flight check (plan 05-12): empty GOOGLE_CLIENT_ID/SECRET → 400 before touching OAuth libs. + """ + from main import app + from config import settings + + auth = await _create_user_and_token(db_session, role="user") + fake_redis = FakeRedis() + app.state.redis = fake_redis + + monkeypatch.setattr(settings, "google_client_id", "") + monkeypatch.setattr(settings, "google_client_secret", "") + + resp = await async_client.get( + "/api/cloud/oauth/initiate/google_drive", + headers=auth["headers"], + follow_redirects=False, + ) + + app.state.redis = None + + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}: {resp.text}" + assert "GOOGLE_CLIENT_ID" in resp.json()["detail"], f"Unexpected detail: {resp.json()['detail']}" + + +async def test_oauth_initiate_onedrive_not_configured(async_client, db_session, monkeypatch): + """GET /api/cloud/oauth/initiate/onedrive returns 400 with env-var hint when creds missing. + + Pre-flight check (plan 05-12): empty ONEDRIVE_CLIENT_ID → 400 before touching MSAL. + """ + from main import app + from config import settings + + auth = await _create_user_and_token(db_session, role="user") + fake_redis = FakeRedis() + app.state.redis = fake_redis + + monkeypatch.setattr(settings, "onedrive_client_id", "") + monkeypatch.setattr(settings, "onedrive_client_secret", "") + + resp = await async_client.get( + "/api/cloud/oauth/initiate/onedrive", + headers=auth["headers"], + follow_redirects=False, + ) + + app.state.redis = None + + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}: {resp.text}" + assert "ONEDRIVE_CLIENT_ID" in resp.json()["detail"], f"Unexpected detail: {resp.json()['detail']}" + + async def test_oauth_initiate_requires_auth(async_client, db_session): """GET /api/cloud/oauth/initiate/google_drive without token returns 401 or 403. diff --git a/backend/tests/test_documents.py b/backend/tests/test_documents.py index 16c4822..991c6f5 100644 --- a/backend/tests/test_documents.py +++ b/backend/tests/test_documents.py @@ -593,3 +593,40 @@ async def test_parse_range_416(async_client, auth_user, db_session, monkeypatch) headers={**auth_user["headers"], "Range": "bytes=100-200"}, ) assert resp.status_code == 416 + + +async def test_stream_document_content_cloud_backend_error(async_client, auth_user, db_session, monkeypatch): + """GET /api/documents/{id}/content returns 502 when cloud backend raises a non-CloudConnectionError exception. + + Plan 05-12 gap closure: broad except-clause catches RuntimeError, timeout, etc. and + returns a user-friendly 502 instead of an opaque 500. + """ + import uuid as _uuid + from unittest.mock import AsyncMock + from db.models import Document + + doc_id = _uuid.uuid4() + doc = Document( + id=doc_id, + user_id=auth_user["user"].id, + filename="cloud_doc.pdf", + content_type="application/pdf", + size_bytes=1024, + storage_backend="google_drive", + status="uploaded", + object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.pdf", + ) + db_session.add(doc) + await db_session.commit() + + async def raise_runtime_error(*args, **kwargs): + raise RuntimeError("connection timeout") + + monkeypatch.setattr("api.documents.get_storage_backend_for_document", raise_runtime_error) + + resp = await async_client.get( + f"/api/documents/{doc_id}/content", + headers=auth_user["headers"], + ) + assert resp.status_code == 502, f"Expected 502, got {resp.status_code}: {resp.text}" + assert "Cloud backend unreachable" in resp.json()["detail"] diff --git a/docker-compose.yml b/docker-compose.yml index 2325ff2..9034b77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,6 +88,8 @@ services: - MINIO_BUCKET=${MINIO_BUCKET} - REDIS_URL=${REDIS_URL} - PYTHONDONTWRITEBYTECODE=1 + volumes: + - ./backend:/app extra_hosts: - "host.docker.internal:host-gateway" command: celery -A celery_app worker --loglevel=info -Q documents diff --git a/frontend/src/views/CloudStorageView.vue b/frontend/src/views/CloudStorageView.vue index 1c0805d..706629c 100644 --- a/frontend/src/views/CloudStorageView.vue +++ b/frontend/src/views/CloudStorageView.vue @@ -50,6 +50,10 @@
+ To upload files, navigate into a cloud folder first. +
+