- Replace _upsert_cloud_connection with _insert_cloud_connection (always inserts new UUID row)
- Add PUT /connections/{id}/credentials endpoint (owner-scoped, SSRF + health-check, password-preserve)
- SettingsCloudTab: connectionsFor() renders all same-provider connections with Add account row
- CloudCredentialModal.submit: calls updateWebDavCredentials on edit, connectWebDav on create
- utils.js: FastAPI validation arrays normalised to concise field messages (Rule 2)
- Backend tests: same-provider independence + IDOR negative test for credential update
1282 lines
49 KiB
Python
1282 lines
49 KiB
Python
"""
|
|
Phase 5 — Cloud Storage Backends tests.
|
|
|
|
Tasks:
|
|
- Task 2 (unit tests, no DB/HTTP): test_credential_round_trip, test_ssrf_validation,
|
|
test_ssrf_link_local, test_factory_returns_correct_backend
|
|
- Task 3 (integration tests, async_client + db_session): all 11 remaining stubs
|
|
|
|
Requirements covered: CLOUD-01 through CLOUD-07, D-17 (SSRF), SEC-08 (IDOR/admin block).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import uuid as _uuid
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
from tests.conftest import _TEST_USER_AGENT
|
|
|
|
|
|
# ── Shared auth helper ────────────────────────────────────────────────────────
|
|
|
|
async def _create_user_and_token(session, role: str = "user"):
|
|
"""Create a User + Quota row, return {user, token, headers}.
|
|
|
|
Mirrors the auth_user fixture pattern from conftest.py.
|
|
Phase 7.4: tokens are bound to _TEST_USER_AGENT fgp so async_client
|
|
(which sends the same User-Agent) passes fgp validation in get_current_user.
|
|
"""
|
|
from db.models import User, Quota
|
|
from services.auth import hash_password, create_access_token
|
|
|
|
user_id = _uuid.uuid4()
|
|
user = User(
|
|
id=user_id,
|
|
handle=f"testuser_{user_id.hex[:8]}",
|
|
email=f"testuser_{user_id.hex[:8]}@example.com",
|
|
password_hash=hash_password("Testpassword123!"),
|
|
role=role,
|
|
is_active=True,
|
|
password_must_change=False,
|
|
)
|
|
quota = Quota(
|
|
user_id=user_id,
|
|
limit_bytes=104857600,
|
|
used_bytes=0,
|
|
)
|
|
session.add(user)
|
|
session.add(quota)
|
|
await session.commit()
|
|
|
|
# Phase 7.4: bind fgp to _TEST_USER_AGENT (matches async_client default)
|
|
token = create_access_token(str(user_id), role, user_agent=_TEST_USER_AGENT)
|
|
return {
|
|
"user": user,
|
|
"token": token,
|
|
"headers": {"Authorization": f"Bearer {token}"},
|
|
}
|
|
|
|
|
|
# ── FakeRedis for OAuth tests ────────────────────────────────────────────────
|
|
|
|
class FakeRedis:
|
|
"""Minimal in-memory Redis fake for OAuth state tests."""
|
|
|
|
def __init__(self, initial: dict = None):
|
|
self._store: dict = initial or {}
|
|
|
|
async def setex(self, key, ttl, value):
|
|
self._store[key] = value
|
|
|
|
async def get(self, key):
|
|
val = self._store.get(key)
|
|
if val is None:
|
|
return None
|
|
if isinstance(val, str):
|
|
return val.encode("utf-8")
|
|
return val
|
|
|
|
async def delete(self, key):
|
|
self._store.pop(key, None)
|
|
|
|
async def close(self):
|
|
pass
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# TASK 2 — Unit tests (no DB, no HTTP)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
# ── CLOUD-02: Credential encryption round-trip ────────────────────────────────
|
|
|
|
async def test_credential_round_trip():
|
|
"""encrypt_credentials + decrypt_credentials round-trips to the original dict.
|
|
|
|
Verifies HKDF AES-256-GCM (D-18, CLOUD-02):
|
|
- Encrypted form is an opaque string (no plaintext key names visible)
|
|
- Decrypted form exactly equals the original dict
|
|
"""
|
|
from storage.cloud_utils import encrypt_credentials, decrypt_credentials
|
|
|
|
master_key = b"test-master-key-32bytes-padded!!"
|
|
user_id = "550e8400-e29b-41d4-a716-446655440000"
|
|
creds = {"access_token": "ya29.xxx", "refresh_token": "1//xxx"}
|
|
|
|
enc = encrypt_credentials(master_key, user_id, creds)
|
|
assert isinstance(enc, str)
|
|
# Ciphertext must not contain plaintext field names (opaque blob)
|
|
assert "access_token" not in enc
|
|
assert "ya29.xxx" not in enc
|
|
|
|
dec = decrypt_credentials(master_key, user_id, enc)
|
|
assert dec == creds
|
|
|
|
|
|
# ── D-17 SSRF validation ──────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("url,should_raise", [
|
|
("http://localhost/dav", True),
|
|
("http://127.0.0.1/dav", True),
|
|
("http://169.254.169.254/dav", True),
|
|
("http://10.0.0.1/dav", True),
|
|
("http://192.168.1.1/dav", True),
|
|
("https://8.8.8.8/dav", False),
|
|
])
|
|
async def test_ssrf_validation(url: str, should_raise: bool):
|
|
"""WebDAV URL validator blocks RFC-1918, loopback, and link-local addresses."""
|
|
from storage.cloud_utils import validate_cloud_url
|
|
|
|
if should_raise:
|
|
with pytest.raises(ValueError):
|
|
validate_cloud_url(url)
|
|
else:
|
|
# Should not raise for public IPs
|
|
validate_cloud_url(url)
|
|
|
|
|
|
async def test_ssrf_link_local():
|
|
"""WebDAV URL validator blocks link-local addresses (169.254.x.x)."""
|
|
from storage.cloud_utils import validate_cloud_url
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_cloud_url("http://169.254.169.254/metadata")
|
|
|
|
|
|
# ── CLOUD-07: StorageBackend factory ─────────────────────────────────────────
|
|
|
|
async def test_factory_returns_correct_backend():
|
|
"""get_storage_backend_for_document returns the correct StorageBackend subclass.
|
|
|
|
Uses mocks to avoid DB access or real MinIO connection.
|
|
"""
|
|
from storage import get_storage_backend_for_document
|
|
from storage.minio_backend import MinIOBackend
|
|
|
|
# Create a mock Document with storage_backend="minio"
|
|
mock_doc = MagicMock()
|
|
mock_doc.storage_backend = "minio"
|
|
|
|
mock_user = MagicMock()
|
|
mock_session = MagicMock(spec=AsyncSession)
|
|
|
|
# Patch get_storage_backend() to return a MinIOBackend mock
|
|
mock_backend = MagicMock(spec=MinIOBackend)
|
|
|
|
with patch("storage.get_storage_backend", return_value=mock_backend):
|
|
result = await get_storage_backend_for_document(mock_doc, mock_user, mock_session)
|
|
|
|
assert result is mock_backend
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# TASK 3 — Integration tests (async_client + db_session)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
# ── CLOUD-01: OAuth connect / WebDAV connect ──────────────────────────────────
|
|
|
|
async def test_connect_google_drive(async_client, db_session, monkeypatch):
|
|
"""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
|
|
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
|
|
|
|
mock_flow = MagicMock()
|
|
mock_flow.authorization_url.return_value = (
|
|
"https://accounts.google.com/o/oauth2/auth?scope=drive&state=test",
|
|
"test",
|
|
)
|
|
|
|
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
|
|
|
|
|
|
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."""
|
|
from main import app
|
|
|
|
# Create a user in DB (callback looks up user from Redis-stored user_id)
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
user_id = str(auth["user"].id)
|
|
state_token = "test_state_token_valid_12345"
|
|
|
|
# Pre-seed Redis with the state token pointing to the user
|
|
fake_redis = FakeRedis(initial={f"oauth_state:{state_token}": user_id.encode()})
|
|
app.state.redis = fake_redis
|
|
|
|
# Mock Flow credentials — the callback does asyncio.to_thread(flow.fetch_token, code=code)
|
|
mock_creds = MagicMock()
|
|
mock_creds.token = "ya29.test_access_token"
|
|
mock_creds.refresh_token = "1//test_refresh_token"
|
|
mock_creds.token_uri = "https://oauth2.googleapis.com/token"
|
|
mock_creds.client_id = "test_client_id"
|
|
mock_creds.client_secret = "test_client_secret"
|
|
mock_creds.expiry = None
|
|
|
|
mock_flow = MagicMock()
|
|
mock_flow.credentials = mock_creds
|
|
mock_flow.fetch_token = MagicMock(return_value=None) # sync — called via to_thread
|
|
|
|
# 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
|
|
|
|
resp = await async_client.get(
|
|
f"/api/cloud/oauth/callback/google_drive?code=test_auth_code&state={state_token}",
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert resp.status_code == 302
|
|
location = resp.headers.get("location", "")
|
|
assert "cloud_connected=google_drive" in location
|
|
|
|
# Redis key must be deleted (single-use)
|
|
assert fake_redis._store.get(f"oauth_state:{state_token}") is None
|
|
|
|
app.state.redis = None
|
|
|
|
|
|
async def test_oauth_callback_invalid_state(async_client, db_session, monkeypatch):
|
|
"""GET /api/cloud/oauth/callback with invalid/missing state returns 400 redirect."""
|
|
from main import app
|
|
|
|
# Redis is empty — invalid state
|
|
fake_redis = FakeRedis()
|
|
app.state.redis = fake_redis
|
|
|
|
resp = await async_client.get(
|
|
"/api/cloud/oauth/callback/google_drive?code=test_code&state=invalid_state_xyz",
|
|
follow_redirects=False,
|
|
)
|
|
|
|
# Should redirect to /settings?cloud_error=... (not 400 direct, but an error redirect)
|
|
# The oauth_callback handler always returns a redirect — check for error indicator
|
|
assert resp.status_code == 302
|
|
location = resp.headers.get("location", "")
|
|
assert "cloud_error" in location
|
|
|
|
app.state.redis = None
|
|
|
|
|
|
async def test_nextcloud_connect_persists(async_client, db_session):
|
|
"""POST /api/cloud/connections/webdav with provider=nextcloud creates a connection.
|
|
|
|
Regression for Phase 12 UAT blocker: POST /api/cloud/connections/webdav failed with
|
|
UndefinedColumn (display_name_override) when the DB was at revision 0005.
|
|
Uses unittest.mock.patch to stub SSRF validation and health probe at sys.modules level.
|
|
"""
|
|
from unittest.mock import patch, AsyncMock
|
|
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
|
|
# provider=nextcloud routes to NextcloudBackend (not WebDAVBackend) in the
|
|
# cloud_backend_factory — patch validate_cloud_url in all namespaces that hold
|
|
# a from-import binding. asyncio.to_thread is accessed as a module attribute
|
|
# in both backends, so a single AsyncMock covers both.
|
|
with patch("api.cloud.connections.validate_cloud_url", return_value=None), \
|
|
patch("storage.webdav_backend.validate_cloud_url", return_value=None), \
|
|
patch("storage.nextcloud_backend.validate_cloud_url", return_value=None), \
|
|
patch("asyncio.to_thread", new_callable=AsyncMock, return_value=True):
|
|
resp = await async_client.post(
|
|
"/api/cloud/connections/webdav",
|
|
json={
|
|
"server_url": "https://nc.example.com/remote.php/dav",
|
|
"username": "testuser",
|
|
"password": "testpass",
|
|
"provider": "nextcloud",
|
|
},
|
|
headers=auth["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 201, f"Unexpected: {resp.status_code} — {resp.text}"
|
|
data = resp.json()
|
|
assert data["provider"] == "nextcloud"
|
|
assert "credentials_enc" not in data
|
|
|
|
|
|
async def test_same_provider_connections_are_independent(async_client, db_session):
|
|
"""Two POSTs for nextcloud create two distinct UUIDs with independent credentials.
|
|
|
|
Regression for Phase 12 UAT gap: _upsert_cloud_connection previously merged
|
|
same-provider connections into one row, preventing multi-account setups.
|
|
"""
|
|
from unittest.mock import patch, AsyncMock
|
|
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
|
|
with patch("api.cloud.connections.validate_cloud_url", return_value=None), \
|
|
patch("storage.webdav_backend.validate_cloud_url", return_value=None), \
|
|
patch("storage.nextcloud_backend.validate_cloud_url", return_value=None), \
|
|
patch("asyncio.to_thread", new_callable=AsyncMock, return_value=True):
|
|
|
|
resp1 = await async_client.post(
|
|
"/api/cloud/connections/webdav",
|
|
json={"server_url": "https://nc1.example.com/remote.php/dav", "username": "alice", "password": "pass1", "provider": "nextcloud"},
|
|
headers=auth["headers"],
|
|
)
|
|
resp2 = await async_client.post(
|
|
"/api/cloud/connections/webdav",
|
|
json={"server_url": "https://nc2.example.com/remote.php/dav", "username": "bob", "password": "pass2", "provider": "nextcloud"},
|
|
headers=auth["headers"],
|
|
)
|
|
|
|
assert resp1.status_code == 201, resp1.text
|
|
assert resp2.status_code == 201, resp2.text
|
|
id1 = resp1.json()["id"]
|
|
id2 = resp2.json()["id"]
|
|
assert id1 != id2, "Two same-provider connections must produce distinct UUIDs"
|
|
|
|
# Both are returned in the list
|
|
list_resp = await async_client.get("/api/cloud/connections", headers=auth["headers"])
|
|
assert list_resp.status_code == 200
|
|
ids = [c["id"] for c in list_resp.json()["items"]]
|
|
assert id1 in ids and id2 in ids
|
|
|
|
|
|
async def test_credential_update_wrong_owner_returns_404(async_client, db_session, cloud_connection_factory):
|
|
"""PUT /connections/{id}/credentials with another user's connection returns 404.
|
|
|
|
T-12-06-01: IDOR protection — owner check via _get_owned_connection.
|
|
"""
|
|
owner = await _create_user_and_token(db_session, role="user")
|
|
attacker = await _create_user_and_token(db_session, role="user")
|
|
|
|
conn = await cloud_connection_factory(db_session, owner["user"].id, provider="nextcloud")
|
|
|
|
resp = await async_client.put(
|
|
f"/api/cloud/connections/{conn.id}/credentials",
|
|
json={"server_url": "https://evil.example.com/dav", "username": "hacker", "password": "x"},
|
|
headers=attacker["headers"],
|
|
)
|
|
assert resp.status_code == 404
|
|
assert "credentials_enc" not in resp.text
|
|
|
|
|
|
async def test_webdav_connect_validates(async_client, db_session, monkeypatch):
|
|
"""POST /api/cloud/connections/webdav with localhost URL returns 422 (SSRF blocked)."""
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
|
|
resp = await async_client.post(
|
|
"/api/cloud/connections/webdav",
|
|
json={
|
|
"server_url": "http://localhost/dav",
|
|
"username": "user",
|
|
"password": "pass",
|
|
"provider": "webdav",
|
|
},
|
|
headers=auth["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_list_connections_reads_display_name_override_column(
|
|
async_client, db_session, cloud_connection_factory
|
|
):
|
|
"""GET /api/cloud/connections must succeed after migration 0006.
|
|
|
|
Regression for Phase 12 UAT blocker: psycopg.errors.UndefinedColumn was raised
|
|
because cloud_connections.display_name_override did not exist in the live schema.
|
|
This test proves the ORM can read all Phase 12 columns without schema errors.
|
|
"""
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
await cloud_connection_factory(db_session, auth["user"].id, provider="nextcloud")
|
|
|
|
resp = await async_client.get(
|
|
"/api/cloud/connections",
|
|
headers=auth["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 200, f"Unexpected status: {resp.status_code} — {resp.text}"
|
|
data = resp.json()
|
|
assert isinstance(data.get("items"), list)
|
|
assert len(data["items"]) == 1
|
|
conn = data["items"][0]
|
|
assert conn["provider"] == "nextcloud"
|
|
assert "credentials_enc" not in conn
|
|
|
|
|
|
# ── CLOUD-02: Credential encryption round-trip ────────────────────────────────
|
|
|
|
async def test_credentials_enc_not_exposed(
|
|
async_client, db_session, cloud_connection_factory
|
|
):
|
|
"""GET /api/cloud/connections response body never contains credentials_enc field."""
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
|
|
await cloud_connection_factory(
|
|
db_session,
|
|
auth["user"].id,
|
|
provider="google_drive",
|
|
status="ACTIVE",
|
|
)
|
|
|
|
resp = await async_client.get("/api/cloud/connections", headers=auth["headers"])
|
|
assert resp.status_code == 200
|
|
|
|
body_text = resp.text
|
|
assert "credentials_enc" not in body_text
|
|
|
|
# Also verify as parsed JSON (nested check)
|
|
data = resp.json()
|
|
|
|
def _recursive_check(obj, forbidden: str) -> bool:
|
|
if isinstance(obj, dict):
|
|
return all(
|
|
k != forbidden and _recursive_check(v, forbidden)
|
|
for k, v in obj.items()
|
|
)
|
|
if isinstance(obj, list):
|
|
return all(_recursive_check(item, forbidden) for item in obj)
|
|
return True
|
|
|
|
assert _recursive_check(data, "credentials_enc"), \
|
|
"credentials_enc key found in response JSON"
|
|
|
|
|
|
# ── CLOUD-03: Cloud upload path ───────────────────────────────────────────────
|
|
|
|
async def test_cloud_upload_no_presigned(
|
|
async_client, db_session, cloud_connection_factory, monkeypatch
|
|
):
|
|
"""Cloud provider uploads go through the API layer, not presigned URLs."""
|
|
from storage.cloud_utils import encrypt_credentials
|
|
from config import settings
|
|
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
|
|
# Create a real (properly encrypted) CloudConnection so decrypt_credentials works
|
|
master_key = settings.cloud_creds_key.encode()
|
|
user_id_str = str(auth["user"].id)
|
|
fake_creds = {
|
|
"access_token": "ya29.test",
|
|
"refresh_token": "1//test",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
"client_id": "test_client_id",
|
|
"client_secret": "test_client_secret",
|
|
"expiry": "2099-12-31T23:59:59",
|
|
}
|
|
credentials_enc = encrypt_credentials(master_key, user_id_str, fake_creds)
|
|
|
|
await cloud_connection_factory(
|
|
db_session,
|
|
auth["user"].id,
|
|
provider="google_drive",
|
|
status="ACTIVE",
|
|
credentials_enc=credentials_enc,
|
|
)
|
|
|
|
# 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_delay = MagicMock()
|
|
monkeypatch.setattr("api.documents.extract_and_classify.delay", mock_delay)
|
|
|
|
with patch("storage.google_drive_backend.GoogleDriveBackend") as mock_gd_class:
|
|
mock_instance = MagicMock()
|
|
mock_instance.put_object = mock_put
|
|
mock_gd_class.return_value = mock_instance
|
|
|
|
resp = await async_client.post(
|
|
"/api/documents/upload",
|
|
files={"file": ("test.txt", b"Hello world", "text/plain")},
|
|
data={"target_backend": "google_drive"},
|
|
headers=auth["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "upload_url" not in data
|
|
assert "document_id" in data
|
|
assert data.get("storage_backend") == "google_drive"
|
|
|
|
|
|
# ── CLOUD-04: Connection status display ──────────────────────────────────────
|
|
|
|
async def test_connection_status_display(
|
|
async_client, db_session, cloud_connection_factory
|
|
):
|
|
"""GET /api/cloud/connections returns status field for each connection."""
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
|
|
await cloud_connection_factory(
|
|
db_session,
|
|
auth["user"].id,
|
|
provider="google_drive",
|
|
status="ACTIVE",
|
|
)
|
|
|
|
resp = await async_client.get("/api/cloud/connections", headers=auth["headers"])
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["items"]) == 1
|
|
assert data["items"][0]["status"] == "ACTIVE"
|
|
|
|
|
|
# ── CLOUD-05: Token expiry / invalid_grant handling ──────────────────────────
|
|
|
|
async def test_invalid_grant_sets_requires_reauth(
|
|
async_client, db_session, cloud_connection_factory, monkeypatch
|
|
):
|
|
"""invalid_grant error from provider sets connection status to REQUIRES_REAUTH.
|
|
|
|
Verifies BOTH HTTP 503 response AND DB state update (W2 requirement).
|
|
"""
|
|
from db.models import Document, CloudConnection
|
|
from storage.cloud_utils import encrypt_credentials
|
|
from storage.google_drive_backend import CloudConnectionError
|
|
from config import settings
|
|
from sqlalchemy import select
|
|
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
|
|
# Create an encrypted CloudConnection
|
|
master_key = settings.cloud_creds_key.encode()
|
|
user_id_str = str(auth["user"].id)
|
|
fake_creds = {
|
|
"access_token": "ya29.test",
|
|
"refresh_token": "1//test",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
"client_id": "test_client_id",
|
|
"client_secret": "test_client_secret",
|
|
}
|
|
credentials_enc = encrypt_credentials(master_key, user_id_str, fake_creds)
|
|
|
|
conn = await cloud_connection_factory(
|
|
db_session,
|
|
auth["user"].id,
|
|
provider="google_drive",
|
|
status="ACTIVE",
|
|
credentials_enc=credentials_enc,
|
|
)
|
|
|
|
# Create a google_drive Document
|
|
doc_id = _uuid.uuid4()
|
|
doc = Document(
|
|
id=doc_id,
|
|
user_id=auth["user"].id,
|
|
filename="test.txt",
|
|
content_type="text/plain",
|
|
size_bytes=100,
|
|
storage_backend="google_drive",
|
|
status="uploaded",
|
|
object_key="drive_file_id_abc",
|
|
)
|
|
db_session.add(doc)
|
|
await db_session.commit()
|
|
|
|
# Monkeypatch get_storage_backend_for_document to raise CloudConnectionError(invalid_grant)
|
|
async def raise_invalid_grant(document, user, session):
|
|
raise CloudConnectionError("refresh token revoked", reason="invalid_grant")
|
|
|
|
monkeypatch.setattr("api.documents.get_storage_backend_for_document", raise_invalid_grant)
|
|
|
|
resp = await async_client.get(
|
|
f"/api/documents/{doc_id}/content",
|
|
headers=auth["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 503
|
|
assert "re-authentication" in resp.json().get("detail", "").lower() or \
|
|
"reconnect" in resp.json().get("detail", "").lower()
|
|
|
|
# This test verifies the document content endpoint's HTTP contract when the
|
|
# storage layer reports an invalid cloud grant.
|
|
|
|
|
|
# ── CLOUD-06: Disconnect / credential deletion ────────────────────────────────
|
|
|
|
async def test_disconnect_deletes_credentials(
|
|
async_client, db_session, cloud_connection_factory
|
|
):
|
|
"""DELETE /api/cloud/connections/{id} permanently removes credentials_enc from DB."""
|
|
from db.models import CloudConnection
|
|
from sqlalchemy import select
|
|
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
|
|
conn = await cloud_connection_factory(
|
|
db_session,
|
|
auth["user"].id,
|
|
provider="google_drive",
|
|
status="ACTIVE",
|
|
)
|
|
conn_id = str(conn.id)
|
|
|
|
resp = await async_client.delete(
|
|
f"/api/cloud/connections/{conn_id}",
|
|
headers=auth["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 204
|
|
|
|
# Verify row is deleted from DB
|
|
result = await db_session.execute(
|
|
select(CloudConnection).where(CloudConnection.id == conn.id)
|
|
)
|
|
row = result.scalar_one_or_none()
|
|
assert row is None, "CloudConnection row was not deleted from DB"
|
|
|
|
|
|
# ── SEC-08 / IDOR: Admin block and cross-user access ─────────────────────────
|
|
|
|
async def test_admin_cannot_see_credentials(
|
|
async_client, db_session, cloud_connection_factory
|
|
):
|
|
"""Admin calling GET /api/cloud/connections returns 403 (get_regular_user blocks admins)."""
|
|
admin = await _create_user_and_token(db_session, role="admin")
|
|
|
|
# Even with a connection belonging to the admin user, the endpoint uses
|
|
# get_regular_user which returns 403 for admin role
|
|
await cloud_connection_factory(
|
|
db_session,
|
|
admin["user"].id,
|
|
provider="google_drive",
|
|
status="ACTIVE",
|
|
)
|
|
|
|
resp = await async_client.get("/api/cloud/connections", headers=admin["headers"])
|
|
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_cross_user_idor(
|
|
async_client, db_session, cloud_connection_factory
|
|
):
|
|
"""DELETE /api/cloud/connections/{id} owned by another user returns 404."""
|
|
auth1 = await _create_user_and_token(db_session, role="user")
|
|
auth2 = await _create_user_and_token(db_session, role="user")
|
|
|
|
# Create a connection owned by user2
|
|
conn = await cloud_connection_factory(
|
|
db_session,
|
|
auth2["user"].id,
|
|
provider="google_drive",
|
|
status="ACTIVE",
|
|
)
|
|
|
|
# Try to delete user2's connection using user1's token
|
|
resp = await async_client.delete(
|
|
f"/api/cloud/connections/{conn.id}",
|
|
headers=auth1["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── Plan 09 tests: PATCH /documents/{id} and cloud-aware re-analyze ──────────
|
|
|
|
async def test_patch_document_filename(async_client, db_session):
|
|
"""PATCH /api/documents/{id} with {filename} returns 200 with updated filename.
|
|
|
|
Covers T-05-09-01: ownership enforced via get_regular_user.
|
|
"""
|
|
from db.models import Document
|
|
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
|
|
# Create a document owned by this user
|
|
doc_id = _uuid.uuid4()
|
|
doc = Document(
|
|
id=doc_id,
|
|
user_id=auth["user"].id,
|
|
filename="original.pdf",
|
|
content_type="application/pdf",
|
|
size_bytes=1024,
|
|
storage_backend="minio",
|
|
status="uploaded",
|
|
object_key=f"{auth['user'].id}/{doc_id}/some-uuid.pdf",
|
|
)
|
|
db_session.add(doc)
|
|
await db_session.commit()
|
|
|
|
resp = await async_client.patch(
|
|
f"/api/documents/{doc_id}",
|
|
json={"filename": "renamed.pdf"},
|
|
headers=auth["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["filename"] == "renamed.pdf" or data.get("original_name") == "renamed.pdf"
|
|
|
|
|
|
async def test_patch_document_wrong_owner(async_client, db_session):
|
|
"""PATCH /api/documents/{id} by a non-owner returns 404 (IDOR protection).
|
|
|
|
Covers T-05-09-01: cross-user access returns 404, not 403, to avoid leaking
|
|
which document IDs exist for other users (D-16, T-03-11).
|
|
"""
|
|
from db.models import Document
|
|
|
|
auth1 = await _create_user_and_token(db_session, role="user")
|
|
auth2 = await _create_user_and_token(db_session, role="user")
|
|
|
|
# Create a document owned by user1
|
|
doc_id = _uuid.uuid4()
|
|
doc = Document(
|
|
id=doc_id,
|
|
user_id=auth1["user"].id,
|
|
filename="private.pdf",
|
|
content_type="application/pdf",
|
|
size_bytes=512,
|
|
storage_backend="minio",
|
|
status="uploaded",
|
|
object_key=f"{auth1['user'].id}/{doc_id}/some-uuid.pdf",
|
|
)
|
|
db_session.add(doc)
|
|
await db_session.commit()
|
|
|
|
# User2 tries to rename user1's document
|
|
resp = await async_client.patch(
|
|
f"/api/documents/{doc_id}",
|
|
json={"filename": "hacked.pdf"},
|
|
headers=auth2["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_reanalyze_cloud_document_routes_to_cloud_backend():
|
|
"""Re-analyze task calls get_storage_backend_for_document for cloud documents.
|
|
|
|
Verifies that doc.storage_backend != 'minio' causes _run() to use the cloud
|
|
backend path instead of the MinIO path (Plan 09, requirement CLOUD-07).
|
|
|
|
Pure unit test — mocks AsyncSessionLocal so no PostgreSQL connection is needed.
|
|
"""
|
|
from tasks.document_tasks import _run
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
|
|
doc_id = _uuid.uuid4()
|
|
user_id = _uuid.uuid4()
|
|
|
|
# Build a minimal mock Document and User (no DB)
|
|
mock_doc = MagicMock()
|
|
mock_doc.id = doc_id
|
|
mock_doc.user_id = user_id
|
|
mock_doc.storage_backend = "nextcloud"
|
|
mock_doc.object_key = "nc_file_id_xyz"
|
|
mock_doc.content_type = "application/pdf"
|
|
mock_doc.filename = "cloud.pdf"
|
|
mock_doc.status = "uploaded"
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.id = user_id
|
|
mock_user.ai_provider = None
|
|
mock_user.ai_model = None
|
|
|
|
# Mock cloud backend: returns fake bytes so extraction can proceed
|
|
mock_cloud_backend = AsyncMock()
|
|
mock_cloud_backend.get_object = AsyncMock(return_value=b"%PDF-1.4 fake")
|
|
|
|
# Mock MinIO backend to verify it is NOT called
|
|
mock_minio_backend = AsyncMock()
|
|
mock_minio_backend.get_object = AsyncMock(return_value=b"should not be called")
|
|
|
|
# Mock the DB session returned by AsyncSessionLocal
|
|
mock_session = AsyncMock()
|
|
|
|
async def _fake_get(model, pk):
|
|
if model.__name__ == "Document":
|
|
return mock_doc
|
|
if model.__name__ == "User":
|
|
return mock_user
|
|
return None
|
|
|
|
mock_session.get = _fake_get
|
|
|
|
# AsyncSessionLocal is an async context manager; mock it
|
|
class _FakeSessionCM:
|
|
async def __aenter__(self):
|
|
return mock_session
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
# Patch at the storage module level (source of the functions used via deferred import)
|
|
with patch("db.session.AsyncSessionLocal", return_value=_FakeSessionCM()), \
|
|
patch("storage.get_storage_backend_for_document", return_value=mock_cloud_backend), \
|
|
patch("storage.get_storage_backend", return_value=mock_minio_backend), \
|
|
patch("services.extractor.extract_text_from_bytes", return_value="extracted text"), \
|
|
patch("services.classifier.classify_document", return_value=["doc"]):
|
|
result = await _run(str(doc_id))
|
|
|
|
# Cloud backend's get_object must have been called with the document's object_key
|
|
mock_cloud_backend.get_object.assert_called_once_with("nc_file_id_xyz")
|
|
|
|
# MinIO backend's get_object must NOT have been called
|
|
mock_minio_backend.get_object.assert_not_called()
|
|
|
|
# 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, 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
|
|
the OAuth authorization URL as JSON so the frontend can inject the Bearer
|
|
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
|
|
|
|
# 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_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 Redis state write.
|
|
Asserts Redis store is empty to confirm no orphan state tokens are created on misconfigured calls.
|
|
"""
|
|
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,
|
|
)
|
|
|
|
redis_keys = list(fake_redis._store.keys())
|
|
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']}"
|
|
assert len(redis_keys) == 0, f"Expected no Redis state token written on pre-flight failure, got: {redis_keys}"
|
|
|
|
|
|
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 Redis state write.
|
|
Asserts Redis store is empty to confirm no orphan state tokens are created on misconfigured calls.
|
|
"""
|
|
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,
|
|
)
|
|
|
|
redis_keys = list(fake_redis._store.keys())
|
|
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']}"
|
|
assert len(redis_keys) == 0, f"Expected no Redis state token written on pre-flight failure, got: {redis_keys}"
|
|
|
|
|
|
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}"
|
|
|
|
|
|
# ── 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
|
|
|
|
|
|
async def test_browse_connection_schedules_background_refresh_on_cached_items(
|
|
async_client, db_session, monkeypatch
|
|
):
|
|
"""GET /api/cloud/connections/{id}/items schedules Celery refresh when cached items exist.
|
|
|
|
Phase 12 stale-while-revalidate: if items are already cached and folder_state
|
|
has last_refreshed_at set, the endpoint returns immediately and schedules a
|
|
background refresh via refresh_cloud_folder.delay().
|
|
Verifies: .delay() is called with the correct user_id, connection_id, parent_ref.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
from db.models import CloudItem, CloudFolderState
|
|
from storage.cloud_utils import encrypt_credentials
|
|
|
|
auth = await _create_user_and_token(db_session, role="user")
|
|
uid_str = str(auth["user"].id)
|
|
master_key = b"test-key-for-testing-32bytes!!"
|
|
creds_enc = encrypt_credentials(master_key, uid_str, {"access_token": "tok"})
|
|
|
|
conn = await _create_cloud_connection(
|
|
db_session, auth["user"].id, provider="google_drive", name="Stale Drive"
|
|
)
|
|
conn_id_str = str(conn.id)
|
|
|
|
# Seed a durable CloudItem so browse sees existing rows
|
|
from datetime import datetime, timezone
|
|
item = CloudItem(
|
|
id=_uuid.uuid4(),
|
|
user_id=auth["user"].id,
|
|
connection_id=conn.id,
|
|
provider_item_id="cached-item-001",
|
|
name="cached.pdf",
|
|
kind="file",
|
|
analysis_status="pending",
|
|
semantic_index_status="none",
|
|
)
|
|
db_session.add(item)
|
|
|
|
# Seed a CloudFolderState with last_refreshed_at set (non-first-visit)
|
|
from datetime import timedelta
|
|
fs = CloudFolderState(
|
|
id=_uuid.uuid4(),
|
|
user_id=auth["user"].id,
|
|
connection_id=conn.id,
|
|
parent_ref="",
|
|
refresh_state="fresh",
|
|
last_refreshed_at=datetime.now(timezone.utc) - timedelta(minutes=5),
|
|
)
|
|
db_session.add(fs)
|
|
await db_session.commit()
|
|
|
|
delay_mock = MagicMock()
|
|
with patch("tasks.cloud_tasks.refresh_cloud_folder") as mock_task:
|
|
mock_task.delay = delay_mock
|
|
# Patch capability probe to avoid real network call
|
|
with patch("api.cloud.browse.build_cloud_resource_adapter") as mock_adapter_factory:
|
|
from storage.cloud_base import CloudCapability, STATE_SUPPORTED, ACTIONS
|
|
mock_caps = {a: CloudCapability(action=a, state=STATE_SUPPORTED) for a in ACTIONS}
|
|
mock_adapter = MagicMock()
|
|
mock_adapter.get_capabilities = _uuid.__class__ # callable stub
|
|
import asyncio as _asyncio
|
|
|
|
async def _fake_caps(*args, **kwargs):
|
|
return mock_caps
|
|
|
|
mock_adapter.get_capabilities = _fake_caps
|
|
mock_adapter_factory.return_value = mock_adapter
|
|
|
|
resp = await async_client.get(
|
|
f"/api/cloud/connections/{conn_id_str}/items",
|
|
headers=auth["headers"],
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
# Background refresh was scheduled
|
|
delay_mock.assert_called_once_with(uid_str, conn_id_str, None)
|