From 9b6d3f91d4577da30a0797ccab6725366cf9328c Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 30 May 2026 11:23:38 +0200 Subject: [PATCH 1/4] test(05-10): add failing tests for OAuth initiate JSON URL return --- backend/tests/test_cloud.py | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index 6d79b7b..2f32416 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -711,3 +711,63 @@ async def test_reanalyze_cloud_document_routes_to_cloud_backend(): # Result must reflect successful classification, not a MinIO error assert result.get("status") in ("classified", "classification_failed"), \ f"Expected classified/classification_failed, got: {result}" + + +# ── Plan 10 tests: OAuth initiate returns JSON URL ──────────────────────────── + + +async def test_oauth_initiate_returns_json_url(async_client, db_session): + """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 + the OAuth authorization URL as JSON so the frontend can inject the Bearer + header before navigating (plan 05-10). + """ + from main import app + + auth = await _create_user_and_token(db_session, role="user") + + # Set up fake Redis so state token storage works + fake_redis = FakeRedis() + app.state.redis = fake_redis + + # Mock google_auth_oauthlib.flow.Flow so no real Google credentials are needed + mock_flow = MagicMock() + mock_flow.authorization_url.return_value = ( + "https://accounts.google.com/test?scope=drive&state=abc", + "abc", + ) + + with patch("google_auth_oauthlib.flow.Flow.from_client_config", return_value=mock_flow): + resp = await async_client.get( + "/api/cloud/oauth/initiate/google_drive", + headers=auth["headers"], + follow_redirects=False, + ) + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" + data = resp.json() + assert "url" in data, f"Response JSON missing 'url' key: {data}" + assert data["url"].startswith("https://accounts.google.com/"), \ + f"OAuth URL does not start with Google domain: {data['url']}" + + # Verify that OAuth state was stored in Redis + stored_keys = list(fake_redis._store.keys()) + assert any(k.startswith("oauth_state:") for k in stored_keys), \ + f"No oauth_state key found in Redis store: {stored_keys}" + + app.state.redis = None + + +async def test_oauth_initiate_requires_auth(async_client, db_session): + """GET /api/cloud/oauth/initiate/google_drive without token returns 401 or 403. + + Security invariant: get_regular_user dependency blocks unauthenticated requests + (T-05-10-01 — authentication enforced on oauth_initiate endpoint). + """ + resp = await async_client.get( + "/api/cloud/oauth/initiate/google_drive", + follow_redirects=False, + ) + assert resp.status_code in (401, 403), \ + f"Expected 401 or 403 for unauthenticated request, got {resp.status_code}" From e2e499b8b10467f21dae0dcd2c57e6bc2edce717 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 30 May 2026 11:24:33 +0200 Subject: [PATCH 2/4] feat(05-10): oauth_initiate returns 200 JSON {url} instead of 302 redirect - Remove response_class=RedirectResponse from @router.get decorator - Replace both RedirectResponse(status_code=302) returns with JSONResponse({url}) - Frontend can now inject Bearer header before navigating to OAuth URL (T-05-10-01) - Update test_connect_google_drive to expect 200 JSON (regression fix) --- backend/api/cloud.py | 16 +++++++++++----- backend/tests/test_cloud.py | 28 ++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/backend/api/cloud.py b/backend/api/cloud.py index 7474c2c..248f266 100644 --- a/backend/api/cloud.py +++ b/backend/api/cloud.py @@ -311,22 +311,28 @@ async def _upsert_cloud_connection( # ── GET /api/cloud/oauth/initiate/{provider} ────────────────────────────────── -@router.get("/oauth/initiate/{provider}", response_class=RedirectResponse) +@router.get("/oauth/initiate/{provider}") async def oauth_initiate( provider: str, request: Request, current_user: User = Depends(get_regular_user), -): +) -> dict: """Start the OAuth flow for Google Drive or OneDrive. Generates a CSRF state token, stores it in Redis with TTL 1800 (30 min), - and redirects the browser to the provider's authorization URL. + and returns the provider's authorization URL as JSON so the frontend can + navigate using fetch() with the Bearer header (plan 05-10 fix). + + Returns: {"url": ""} Security: - state token is secrets.token_urlsafe(32) — 256 bits of entropy (T-05-05-01) - Redis key is single-use: deleted in the callback handler (T-05-05-02) - Only google_drive and onedrive are accepted (T-05-05-06) + - Endpoint requires get_regular_user — no unauthenticated access (T-05-10-01) """ + from fastapi.responses import JSONResponse # already available via fastapi + if provider not in VALID_OAUTH_PROVIDERS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -359,7 +365,7 @@ async def oauth_initiate( prompt="consent", state=state_token, ) - return RedirectResponse(url=authorization_url, status_code=302) + return JSONResponse({"url": authorization_url}) elif provider == "onedrive": import msal # lazy import @@ -375,7 +381,7 @@ async def oauth_initiate( redirect_uri=redirect_uri, state=state_token, ) - return RedirectResponse(url=auth_url, status_code=302) + return JSONResponse({"url": auth_url}) # ── GET /api/cloud/oauth/callback/{provider} ────────────────────────────────── diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index 2f32416..2cfb423 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -178,7 +178,11 @@ async def test_factory_returns_correct_backend(): # ── CLOUD-01: OAuth connect / WebDAV connect ────────────────────────────────── async def test_connect_google_drive(async_client, db_session, monkeypatch): - """GET /api/cloud/oauth/initiate/google_drive redirects to Google's OAuth URL.""" + """GET /api/cloud/oauth/initiate/google_drive returns 200 JSON {url} pointing to Google OAuth. + + Updated in plan 05-10: endpoint now returns JSON instead of 302 redirect + so the frontend can inject the Bearer Authorization header before navigating. + """ from main import app auth = await _create_user_and_token(db_session, role="user") @@ -187,15 +191,23 @@ async def test_connect_google_drive(async_client, db_session, monkeypatch): fake_redis = FakeRedis() app.state.redis = fake_redis - resp = await async_client.get( - "/api/cloud/oauth/initiate/google_drive", - headers=auth["headers"], - follow_redirects=False, + mock_flow = MagicMock() + mock_flow.authorization_url.return_value = ( + "https://accounts.google.com/o/oauth2/auth?scope=drive&state=test", + "test", ) - assert resp.status_code == 302 - location = resp.headers.get("location", "") - assert "accounts.google.com" in location + with patch("google_auth_oauthlib.flow.Flow.from_client_config", return_value=mock_flow): + resp = await async_client.get( + "/api/cloud/oauth/initiate/google_drive", + headers=auth["headers"], + follow_redirects=False, + ) + + assert resp.status_code == 200 + data = resp.json() + assert "url" in data + assert "accounts.google.com" in data["url"] # Clean up app.state.redis = None From 87de148a592ae44e2949ea020abf446f1e97e682 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 30 May 2026 11:30:13 +0200 Subject: [PATCH 3/4] feat(05-10): OAuth fetch + Nextcloud edit fix + Edit on ERROR + text overflow - client.js: add initiateOAuth() and getConnectionConfig() helpers - SettingsCloudTab: replace window.location.href with initiateOAuth() + fetch/JWT - SettingsCloudTab: add Edit button to ACTIVE and ERROR blocks for non-OAuth providers - SettingsCloudTab: wrap ConfirmBlock in w-full overflow-hidden div - CloudCredentialModal: add existing prop, edit-mode pre-population via /config endpoint - CloudCredentialModal: add showAdvanced + customEndpoint for Nextcloud custom paths - ConfirmBlock: add break-words class to message paragraph - cloud.py: add GET /api/cloud/connections/{id}/config endpoint (non-secret fields) --- backend/api/cloud.py | 51 ++++++ frontend/src/api/client.js | 24 +++ .../components/cloud/CloudCredentialModal.vue | 167 ++++++++++++++++-- .../components/settings/SettingsCloudTab.vue | 124 +++++++++---- frontend/src/components/ui/ConfirmBlock.vue | 2 +- 5 files changed, 310 insertions(+), 58 deletions(-) diff --git a/backend/api/cloud.py b/backend/api/cloud.py index 248f266..d35a7e1 100644 --- a/backend/api/cloud.py +++ b/backend/api/cloud.py @@ -642,6 +642,57 @@ async def list_connections( return {"items": [CloudConnectionOut.model_validate(c).model_dump() for c in connections]} +# ── GET /api/cloud/connections/{connection_id}/config ──────────────────────── + + +@router.get("/connections/{connection_id}/config") +async def get_connection_config( + connection_id: uuid.UUID, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_regular_user), +) -> dict: + """Return non-secret configuration fields for a WebDAV/Nextcloud connection. + + Returns server_url and connection_username (not password) so the frontend + can pre-populate the Edit modal without exposing credentials. + + Only applicable to WebDAV / Nextcloud connections (not OAuth providers). + Returns 404 for wrong-owner or unknown connections (prevents ID enumeration). + Returns 400 for OAuth providers (no non-secret config to return). + + Security: + - Only connection owned by current_user.id is returned (T-05-05-04) + - password is never included in the response (D-18) + - Returns 404 for wrong-owner connections (prevents ID enumeration) + """ + conn = await session.get(CloudConnection, connection_id) + if conn is None or conn.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found") + + if conn.provider not in VALID_WEBDAV_PROVIDERS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Connection config is only available for WebDAV/Nextcloud connections", + ) + + master_key = settings.cloud_creds_key.encode() + try: + credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc) + except Exception: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to decrypt connection credentials", + ) + + # Return non-secret fields only — never expose the password + return { + "id": str(conn.id), + "provider": conn.provider, + "server_url": credentials.get("server_url", ""), + "connection_username": credentials.get("username", ""), + } + + # ── DELETE /api/cloud/connections/{connection_id} ───────────────────────────── diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index aa7da8c..2b5b730 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -437,3 +437,27 @@ export function updateDefaultStorage(backend) { export function getCloudFolders(provider, folderId) { return request(`/api/cloud/folders/${provider}/${folderId}`) } + +/** + * Initiate OAuth flow for Google Drive or OneDrive. + * + * Returns a JSON object {url: ""} from the backend. + * The caller is responsible for navigating: window.location.href = data.url + * + * Using request() (not bare window.location.href) ensures the Bearer header + * is injected and the 401→refresh retry path fires if the token has expired. + * See plan 05-10 trust boundary: frontend→/api/cloud/oauth/initiate/{provider}. + */ +export function initiateOAuth(provider) { + return request(`/api/cloud/oauth/initiate/${provider}`) +} + +/** + * Fetch non-secret configuration for a WebDAV/Nextcloud connection (edit flow). + * + * Returns {id, provider, server_url, connection_username} — never the password. + * Used to pre-populate the Edit modal when re-editing an existing connection. + */ +export function getConnectionConfig(connectionId) { + return request(`/api/cloud/connections/${connectionId}/config`) +} diff --git a/frontend/src/components/cloud/CloudCredentialModal.vue b/frontend/src/components/cloud/CloudCredentialModal.vue index 81f85b7..5c7e3e3 100644 --- a/frontend/src/components/cloud/CloudCredentialModal.vue +++ b/frontend/src/components/cloud/CloudCredentialModal.vue @@ -8,7 +8,9 @@
-

Connect {{ provider?.label }}

+

+ {{ existing ? 'Edit' : 'Connect' }} {{ provider?.label }} +

+ +
+ Loading connection settings... +
+ -
- -
+ + +
+ + +

+ Your Nextcloud server address. The WebDAV path is constructed automatically. +

+
+ + +

Full WebDAV endpoint URL including username path segment.

@@ -45,6 +66,36 @@ />
+ +
+ +
+ + +

+ Override the automatically-constructed WebDAV path. Leave empty to use the default. +

+
+
+

Authentication method

@@ -90,8 +141,12 @@ type="password" v-model="password" autocomplete="current-password" + :placeholder="existing ? 'Leave empty to keep current password' : ''" class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" /> +

+ Password is not displayed for security. Enter a new password to change it, or leave empty to keep the current one. +

@@ -111,7 +166,7 @@ @click="close" class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" > - Keep current settings + {{ existing ? 'Cancel' : 'Keep current settings' }}
@@ -131,7 +186,7 @@