diff --git a/backend/storage/cloud_backend_factory.py b/backend/storage/cloud_backend_factory.py index e6458e0..f7c710d 100644 --- a/backend/storage/cloud_backend_factory.py +++ b/backend/storage/cloud_backend_factory.py @@ -1,4 +1,7 @@ """Factory for user-scoped cloud storage backends.""" +from __future__ import annotations + +from storage.cloud_base import CloudResourceAdapter def build_cloud_backend(provider: str, credentials: dict): @@ -31,3 +34,22 @@ def build_cloud_backend(provider: str, credentials: dict): ) raise ValueError(f"Unknown provider: {provider}") + + +def build_cloud_resource_adapter(provider: str, credentials: dict) -> CloudResourceAdapter: + """Build a CloudResourceAdapter for the given provider and credentials. + + Returns a provider instance that implements the CloudResourceAdapter read-only + interface (list_folder, get_capabilities, merge_item_capabilities). All four + supported providers implement CloudResourceAdapter as a mixin alongside + their StorageBackend interface. + + Raises: + ValueError: If provider is not one of the four supported cloud providers. + """ + backend = build_cloud_backend(provider, credentials) + if not isinstance(backend, CloudResourceAdapter): + raise ValueError( + f"Provider {provider!r} backend does not implement CloudResourceAdapter" + ) + return backend diff --git a/backend/storage/google_drive_backend.py b/backend/storage/google_drive_backend.py index 1419e05..66acd77 100644 --- a/backend/storage/google_drive_backend.py +++ b/backend/storage/google_drive_backend.py @@ -27,6 +27,7 @@ import asyncio import datetime import io import uuid +from typing import Optional from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials @@ -35,10 +36,41 @@ from googleapiclient.errors import HttpError from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload from storage.base import StorageBackend +from storage.cloud_base import ( + ACTIONS, + REASON_INSUFFICIENT_SCOPE, + REASON_PROVIDER_UNSUPPORTED, + REASON_REAUTH_REQUIRED, + STATE_SUPPORTED, + STATE_TEMPORARILY_UNAVAILABLE, + STATE_UNSUPPORTED, + CloudCapability, + CloudListing, + CloudResource, + CloudResourceAdapter, +) from storage.exceptions import CloudConnectionError # noqa: F401 re-exported for import compatibility +# Fields requested on every Drive files().list() call — explicit whitelist, no bytes +_DRIVE_LIST_FIELDS = ( + "nextPageToken," + "files(id,name,mimeType,size,modifiedTime,md5Checksum,parents," + "capabilities(canEdit,canDelete,canRename,canMoveItemWithinDrive))" +) -class GoogleDriveBackend(StorageBackend): +_GOOGLE_NATIVE_MIMETYPES = frozenset({ + "application/vnd.google-apps.document", + "application/vnd.google-apps.spreadsheet", + "application/vnd.google-apps.presentation", + "application/vnd.google-apps.form", + "application/vnd.google-apps.drawing", + "application/vnd.google-apps.map", + "application/vnd.google-apps.site", + "application/vnd.google-apps.script", +}) + + +class GoogleDriveBackend(StorageBackend, CloudResourceAdapter): """Google Drive v3 implementation of StorageBackend. Every sync googleapiclient call is wrapped in asyncio.to_thread() (Pitfall 7). @@ -238,3 +270,148 @@ class GoogleDriveBackend(StorageBackend): return await asyncio.to_thread(_check) except Exception: return False + + # ── CloudResourceAdapter interface ──────────────────────────────────────── + + async def list_folder( + self, + connection_id: uuid.UUID, + user_id: uuid.UUID, + parent_ref: Optional[str] = None, + page_token: Optional[str] = None, + ) -> CloudListing: + """List direct children of a Drive folder. + + parent_ref=None browses the Drive root ('root'). + Follows nextPageToken for complete pagination before returning. + Native Google document types (Docs, Sheets, etc.) are represented with + size=None — never treated as folders. + Never downloads file bytes or mutates provider content. + """ + folder_id = parent_ref if parent_ref else "root" + + def _list_all() -> tuple[list[dict], bool]: + service = self._get_service() + items: list[dict] = [] + next_token = page_token + complete = True + try: + while True: + params: dict = { + "q": f"'{folder_id}' in parents and trashed=false", + "fields": _DRIVE_LIST_FIELDS, + "pageSize": 200, + } + if next_token: + params["pageToken"] = next_token + resp = service.files().list(**params).execute() + items.extend(resp.get("files", [])) + next_token = resp.get("nextPageToken") + if not next_token: + break + except HttpError as exc: + complete = False + if exc.resp.status in (401, 403): + pass # Will produce empty items with complete=False + return items, complete + + try: + raw_items, complete = await asyncio.to_thread(_list_all) + except Exception: + return CloudListing(items=(), complete=False) + + resources: list[CloudResource] = [] + for item in raw_items: + mime = item.get("mimeType", "") + is_native = mime in _GOOGLE_NATIVE_MIMETYPES + is_folder = mime == "application/vnd.google-apps.folder" + kind = "folder" if is_folder else "file" + # Native docs have no binary size — represent as None + size: Optional[int] = None + if not is_native and not is_folder: + raw_size = item.get("size") + if raw_size is not None: + try: + size = int(raw_size) + except (TypeError, ValueError): + size = None + modified_str = item.get("modifiedTime") + modified_at: Optional[datetime.datetime] = None + if modified_str: + try: + modified_at = datetime.datetime.fromisoformat( + modified_str.replace("Z", "+00:00") + ) + except ValueError: + pass + resources.append( + CloudResource( + id=uuid.uuid4(), + provider_item_id=item["id"], + connection_id=connection_id, + user_id=user_id, + name=item.get("name", ""), + kind=kind, + parent_ref=parent_ref, + content_type=mime if not is_folder else None, + size=size, + modified_at=modified_at, + etag=item.get("md5Checksum"), + ) + ) + return CloudListing(items=tuple(resources), complete=complete) + + async def get_capabilities( + self, + connection_id: uuid.UUID, + user_id: uuid.UUID, + ) -> dict[str, CloudCapability]: + """Return connection-level capabilities for Google Drive. + + Uses drive.file scope — browse is supported; mutations pending Phase 13. + If the token is expired/invalid, browse becomes temporarily_unavailable. + Never creates, renames, moves, or deletes provider content. + """ + def _check_scope() -> bool: + """Verify browse access with a minimal listing call.""" + service = self._get_service() + service.files().list(pageSize=1, fields="files(id)").execute() + return True + + try: + await asyncio.to_thread(_check_scope) + auth_ok = True + except HttpError as exc: + auth_ok = exc.resp.status not in (401, 403) + except Exception: + auth_ok = False + + caps: dict[str, CloudCapability] = {} + for action in ACTIONS: + if action == "browse": + if auth_ok: + caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED) + else: + caps[action] = CloudCapability( + action=action, + state=STATE_TEMPORARILY_UNAVAILABLE, + reason=REASON_REAUTH_REQUIRED, + message="Re-authenticate to browse Google Drive.", + ) + elif action in ("open", "preview"): + # Phase 13 mutations — not yet implemented + caps[action] = CloudCapability( + action=action, + state=STATE_UNSUPPORTED, + reason=REASON_PROVIDER_UNSUPPORTED, + message="Not available in the current phase.", + ) + else: + # upload, create_folder, rename, move, delete, change_tracking — Phase 13 + caps[action] = CloudCapability( + action=action, + state=STATE_UNSUPPORTED, + reason=REASON_PROVIDER_UNSUPPORTED, + message="Not available in the current phase.", + ) + return caps diff --git a/backend/storage/onedrive_backend.py b/backend/storage/onedrive_backend.py index 01300e6..ad28dc6 100644 --- a/backend/storage/onedrive_backend.py +++ b/backend/storage/onedrive_backend.py @@ -26,19 +26,32 @@ import asyncio import datetime import io import uuid +from typing import Optional import httpx import msal from config import settings from storage.base import StorageBackend +from storage.cloud_base import ( + ACTIONS, + REASON_PROVIDER_UNSUPPORTED, + REASON_REAUTH_REQUIRED, + STATE_SUPPORTED, + STATE_TEMPORARILY_UNAVAILABLE, + STATE_UNSUPPORTED, + CloudCapability, + CloudListing, + CloudResource, + CloudResourceAdapter, +) from storage.google_drive_backend import CloudConnectionError # reuse shared exception GRAPH_BASE = "https://graph.microsoft.com/v1.0" CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB — above Graph's 4 MB simple upload limit (Pitfall 6) -class OneDriveBackend(StorageBackend): +class OneDriveBackend(StorageBackend, CloudResourceAdapter): """Microsoft Graph / OneDrive implementation of StorageBackend. Uses MSAL for token management and httpx for async HTTP to Microsoft Graph. @@ -276,3 +289,130 @@ class OneDriveBackend(StorageBackend): return r.is_success except Exception: return False + + # ── CloudResourceAdapter interface ──────────────────────────────────────── + + async def list_folder( + self, + connection_id: uuid.UUID, + user_id: uuid.UUID, + parent_ref: Optional[str] = None, + page_token: Optional[str] = None, + ) -> CloudListing: + """List direct children of a OneDrive folder. + + parent_ref=None browses the drive root. + Follows @odata.nextLink for complete pagination. + Never downloads file bytes or mutates provider content. + """ + try: + await self._ensure_valid_token() + except CloudConnectionError: + return CloudListing(items=(), complete=False) + + if parent_ref: + url = f"{GRAPH_BASE}/me/drive/items/{parent_ref}/children" + else: + url = f"{GRAPH_BASE}/me/drive/root/children" + + # $select: id, name, folder/file facets, size, modified, eTag/cTag + select = "id,name,folder,file,size,lastModifiedDateTime,eTag,cTag,parentReference" + if page_token: + url = page_token # nextLink already embeds select/expand + + resources: list[CloudResource] = [] + complete = True + try: + async with httpx.AsyncClient() as client: + while url: + r = await client.get( + url, + headers=self._auth_headers(), + params={"$select": select} if not page_token else None, + timeout=30, + ) + if not r.is_success: + complete = False + break + data = r.json() + for item in data.get("value", []): + is_folder = "folder" in item + kind = "folder" if is_folder else "file" + size: Optional[int] = item.get("size") if not is_folder else None + mod_str = item.get("lastModifiedDateTime") + modified_at: Optional[datetime.datetime] = None + if mod_str: + try: + modified_at = datetime.datetime.fromisoformat( + mod_str.replace("Z", "+00:00") + ) + except ValueError: + pass + content_type: Optional[str] = None + if "file" in item: + content_type = item["file"].get("mimeType") + parent_ref_val = item.get("parentReference", {}).get("id") + resources.append( + CloudResource( + id=uuid.uuid4(), + provider_item_id=item["id"], + connection_id=connection_id, + user_id=user_id, + name=item.get("name", ""), + kind=kind, + parent_ref=parent_ref, + content_type=content_type, + size=size, + modified_at=modified_at, + etag=item.get("eTag") or item.get("cTag"), + ) + ) + url = data.get("@odata.nextLink") + except Exception: + complete = False + + return CloudListing(items=tuple(resources), complete=complete) + + async def get_capabilities( + self, + connection_id: uuid.UUID, + user_id: uuid.UUID, + ) -> dict[str, CloudCapability]: + """Return connection-level capabilities for OneDrive. + + Browse is supported when token is valid; mutations pending Phase 13. + Never creates, renames, moves, or deletes provider content. + """ + try: + await self._ensure_valid_token() + async with httpx.AsyncClient() as client: + r = await client.get( + f"{GRAPH_BASE}/me/drive", + params={"$select": "id"}, + headers=self._auth_headers(), + timeout=10, + ) + auth_ok = r.is_success + except Exception: + auth_ok = False + + caps: dict[str, CloudCapability] = {} + for action in ACTIONS: + if action == "browse": + if auth_ok: + caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED) + else: + caps[action] = CloudCapability( + action=action, + state=STATE_TEMPORARILY_UNAVAILABLE, + reason=REASON_REAUTH_REQUIRED, + message="Re-authenticate to browse OneDrive.", + ) + else: + caps[action] = CloudCapability( + action=action, + state=STATE_UNSUPPORTED, + reason=REASON_PROVIDER_UNSUPPORTED, + message="Not available in the current phase.", + ) + return caps diff --git a/backend/storage/webdav_backend.py b/backend/storage/webdav_backend.py index f7d3848..ffe7e2e 100644 --- a/backend/storage/webdav_backend.py +++ b/backend/storage/webdav_backend.py @@ -29,16 +29,31 @@ from __future__ import annotations import asyncio import io +import uuid import urllib.parse +from datetime import datetime, timezone from pathlib import Path +from typing import Optional from webdav3.client import Client from storage.base import StorageBackend +from storage.cloud_base import ( + ACTIONS, + REASON_OFFLINE, + REASON_PROVIDER_UNSUPPORTED, + STATE_SUPPORTED, + STATE_TEMPORARILY_UNAVAILABLE, + STATE_UNSUPPORTED, + CloudCapability, + CloudListing, + CloudResource, + CloudResourceAdapter, +) from storage.cloud_utils import validate_cloud_url -class WebDAVBackend(StorageBackend): +class WebDAVBackend(StorageBackend, CloudResourceAdapter): """Generic WebDAV storage backend implementing all 7 StorageBackend abstract methods. All synchronous webdavclient3 calls are wrapped in asyncio.to_thread() to avoid @@ -220,3 +235,136 @@ class WebDAVBackend(StorageBackend): return bool(result) except Exception: return False + + # ── CloudResourceAdapter interface ──────────────────────────────────────── + + async def list_folder( + self, + connection_id: uuid.UUID, + user_id: uuid.UUID, + parent_ref: Optional[str] = None, + page_token: Optional[str] = None, + ) -> CloudListing: + """List direct children of a WebDAV folder via non-mutating PROPFIND. + + parent_ref=None lists the WebDAV root. + Uses client.list() + client.info() — no file byte download, no mutation. + SSRF guard is re-validated before every outbound request (D-17). + Absent properties treated as unknown (not unsupported). + """ + folder_path = parent_ref if parent_ref else "" + + def _propfind() -> list[dict]: + validate_cloud_url(self._server_url) + items = self._client.list(folder_path) + folder_norm = folder_path.strip("/") + result: list[dict] = [] + for name in items: + if not name or name in (".", "./"): + continue + if name.strip("/") == folder_norm: + continue + if folder_path: + item_path = f"{folder_path.rstrip('/')}/{name}" + else: + item_path = name + validate_cloud_url(self._server_url) + try: + info = self._client.info(item_path) + size_raw = info.get("size") + size = int(size_raw) if size_raw is not None else 0 + is_dir = ( + info.get("isdir", False) + or str(info.get("content_type", "")).startswith("httpd/unix-directory") + or name.endswith("/") + ) + etag = info.get("etag") or info.get("getetag") + mod_str = info.get("modified") or info.get("getlastmodified") + content_type = info.get("content_type") or info.get("getcontenttype") + except Exception: + size = 0 + is_dir = name.endswith("/") + etag = None + mod_str = None + content_type = None + result.append({ + "path": item_path, + "name": name.rstrip("/"), + "is_dir": is_dir, + "size": size, + "etag": etag, + "modified": mod_str, + "content_type": content_type, + }) + return result + + try: + raw_items = await asyncio.to_thread(_propfind) + complete = True + except Exception: + return CloudListing(items=(), complete=False) + + resources: list[CloudResource] = [] + for item in raw_items: + kind = "folder" if item["is_dir"] else "file" + size: Optional[int] = None if item["is_dir"] else item["size"] + modified_at: Optional[datetime] = None + if item.get("modified"): + try: + modified_at = datetime.fromisoformat(item["modified"]) + except (ValueError, TypeError): + pass + resources.append( + CloudResource( + id=uuid.uuid4(), + provider_item_id=item["path"], + connection_id=connection_id, + user_id=user_id, + name=item["name"], + kind=kind, + parent_ref=parent_ref, + content_type=item.get("content_type") if not item["is_dir"] else None, + size=size, + modified_at=modified_at, + etag=item.get("etag"), + ) + ) + return CloudListing(items=tuple(resources), complete=complete) + + async def get_capabilities( + self, + connection_id: uuid.UUID, + user_id: uuid.UUID, + ) -> dict[str, CloudCapability]: + """Return connection-level capabilities for WebDAV. + + Uses non-mutating OPTIONS probe to verify server reachability. + WebDAV supports browse; mutations pending Phase 13. + Never creates, renames, moves, or deletes provider content. + """ + try: + validate_cloud_url(self._server_url) + reachable = await asyncio.to_thread(self._client.check, "/") + except Exception: + reachable = False + + caps: dict[str, CloudCapability] = {} + for action in ACTIONS: + if action == "browse": + if reachable: + caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED) + else: + caps[action] = CloudCapability( + action=action, + state=STATE_TEMPORARILY_UNAVAILABLE, + reason=REASON_OFFLINE, + message="WebDAV server is unreachable.", + ) + else: + caps[action] = CloudCapability( + action=action, + state=STATE_UNSUPPORTED, + reason=REASON_PROVIDER_UNSUPPORTED, + message="Not available in the current phase.", + ) + return caps diff --git a/backend/tests/test_cloud_backends.py b/backend/tests/test_cloud_backends.py index c8fe3a6..438f981 100644 --- a/backend/tests/test_cloud_backends.py +++ b/backend/tests/test_cloud_backends.py @@ -283,3 +283,357 @@ class TestOneDriveEnsureValidToken: with pytest.raises(CloudConnectionError) as exc_info: await backend._ensure_valid_token() assert exc_info.value.reason == "invalid_grant" + + +# ── Phase 12: CloudResourceAdapter contract tests ──────────────────────────── + +class TestCloudResourceAdapterContract: + """Verify all four providers implement the CloudResourceAdapter interface.""" + + def test_google_drive_is_adapter(self): + from storage.google_drive_backend import GoogleDriveBackend + from storage.cloud_base import CloudResourceAdapter + assert issubclass(GoogleDriveBackend, CloudResourceAdapter) + + def test_onedrive_is_adapter(self): + from storage.onedrive_backend import OneDriveBackend + from storage.cloud_base import CloudResourceAdapter + assert issubclass(OneDriveBackend, CloudResourceAdapter) + + def test_webdav_is_adapter(self): + from storage.webdav_backend import WebDAVBackend + from storage.cloud_base import CloudResourceAdapter + assert issubclass(WebDAVBackend, CloudResourceAdapter) + + def test_nextcloud_is_adapter(self): + from storage.nextcloud_backend import NextcloudBackend + from storage.cloud_base import CloudResourceAdapter + assert issubclass(NextcloudBackend, CloudResourceAdapter) + + def test_factory_build_cloud_resource_adapter(self): + from storage.cloud_backend_factory import build_cloud_resource_adapter + from storage.cloud_base import CloudResourceAdapter + adapter = build_cloud_resource_adapter( + "google_drive", + { + "access_token": "tok", + "refresh_token": "ref", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "cid", + "client_secret": "csec", + }, + ) + assert isinstance(adapter, CloudResourceAdapter) + + def test_factory_rejects_unknown_provider(self): + from storage.cloud_backend_factory import build_cloud_resource_adapter + with pytest.raises(ValueError, match="Unknown provider"): + build_cloud_resource_adapter("s3", {}) + + +class TestGoogleDriveListFolder: + """list_folder must never call get_object, put, delete, or move.""" + + def _make_backend(self): + from storage.google_drive_backend import GoogleDriveBackend + return GoogleDriveBackend({ + "access_token": "tok", + "refresh_token": "ref", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "cid", + "client_secret": "csec", + }) + + @pytest.mark.asyncio + async def test_list_folder_returns_cloud_listing(self): + from storage.cloud_base import CloudListing + from unittest.mock import MagicMock, patch + import uuid + + backend = self._make_backend() + fake_service = MagicMock() + fake_service.files().list().execute.return_value = { + "files": [ + {"id": "f1", "name": "Report.pdf", "mimeType": "application/pdf", "size": "1024"}, + {"id": "d1", "name": "Folder", "mimeType": "application/vnd.google-apps.folder"}, + ] + } + with patch.object(backend, "_get_service", return_value=fake_service): + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + assert isinstance(result, CloudListing) + assert len(result.items) == 2 + file_item = next(i for i in result.items if i.kind == "file") + folder_item = next(i for i in result.items if i.kind == "folder") + assert file_item.name == "Report.pdf" + assert folder_item.name == "Folder" + + @pytest.mark.asyncio + async def test_list_folder_follows_pagination(self): + from storage.cloud_base import CloudListing + from unittest.mock import MagicMock, patch, call + import uuid + + backend = self._make_backend() + fake_service = MagicMock() + # First page returns nextPageToken; second page does not + fake_service.files().list().execute.side_effect = [ + { + "files": [{"id": "f1", "name": "File1.pdf", "mimeType": "application/pdf", "size": "100"}], + "nextPageToken": "tok2", + }, + { + "files": [{"id": "f2", "name": "File2.pdf", "mimeType": "application/pdf", "size": "200"}], + }, + ] + with patch.object(backend, "_get_service", return_value=fake_service): + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + assert result.complete is True + assert len(result.items) == 2 + + @pytest.mark.asyncio + async def test_list_folder_native_doc_size_is_none(self): + from unittest.mock import MagicMock, patch + import uuid + + backend = self._make_backend() + fake_service = MagicMock() + fake_service.files().list().execute.return_value = { + "files": [ + { + "id": "gdoc1", + "name": "My Doc", + "mimeType": "application/vnd.google-apps.document", + }, + ] + } + with patch.object(backend, "_get_service", return_value=fake_service): + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + assert len(result.items) == 1 + assert result.items[0].size is None + assert result.items[0].kind == "file" # native docs are not folders + + @pytest.mark.asyncio + async def test_list_folder_never_calls_get_object(self): + """list_folder must never invoke get_object (byte download guard).""" + from unittest.mock import MagicMock, patch + import uuid + + backend = self._make_backend() + fake_service = MagicMock() + fake_service.files().list().execute.return_value = {"files": []} + with patch.object(backend, "_get_service", return_value=fake_service): + with patch.object(backend, "get_object", side_effect=AssertionError("get_object called")) as mock_get: + await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + mock_get.assert_not_called() + + @pytest.mark.asyncio + async def test_list_folder_incomplete_on_http_error(self): + from unittest.mock import MagicMock, patch + from googleapiclient.errors import HttpError + import uuid + + backend = self._make_backend() + fake_service = MagicMock() + resp = MagicMock() + resp.status = 403 + fake_service.files().list().execute.side_effect = HttpError(resp, b"forbidden") + with patch.object(backend, "_get_service", return_value=fake_service): + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + assert result.complete is False + + +class TestOneDriveListFolder: + """list_folder must never call get_object, put, delete, or move.""" + + def _make_backend(self): + from storage.onedrive_backend import OneDriveBackend + return OneDriveBackend({ + "access_token": "tok", + "refresh_token": "ref", + "expires_at": "2099-01-01T00:00:00", + }) + + @pytest.mark.asyncio + async def test_list_folder_returns_cloud_listing(self): + from storage.cloud_base import CloudListing + from unittest.mock import patch, MagicMock, AsyncMock + import uuid + import httpx + + backend = self._make_backend() + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = { + "value": [ + {"id": "item1", "name": "Doc.docx", "file": {"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, "size": 4096}, + {"id": "folder1", "name": "Projects", "folder": {"childCount": 3}}, + ] + } + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.get = AsyncMock(return_value=mock_resp) + mock_client_cls.return_value = mock_client + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + + assert isinstance(result, CloudListing) + assert len(result.items) == 2 + file_item = next(i for i in result.items if i.kind == "file") + folder_item = next(i for i in result.items if i.kind == "folder") + assert file_item.name == "Doc.docx" + assert folder_item.name == "Projects" + + @pytest.mark.asyncio + async def test_list_folder_never_calls_get_object(self): + """list_folder must never invoke get_object.""" + from unittest.mock import patch, MagicMock, AsyncMock + import uuid + + backend = self._make_backend() + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = {"value": []} + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.get = AsyncMock(return_value=mock_resp) + mock_client_cls.return_value = mock_client + with patch.object(backend, "get_object", side_effect=AssertionError("get_object called")) as mock_get: + await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + mock_get.assert_not_called() + + @pytest.mark.asyncio + async def test_list_folder_follows_odata_next_link(self): + """list_folder follows @odata.nextLink until exhausted.""" + from unittest.mock import patch, MagicMock, AsyncMock + import uuid + + backend = self._make_backend() + page1 = MagicMock() + page1.is_success = True + page1.json.return_value = { + "value": [{"id": "i1", "name": "A.txt", "file": {}, "size": 10}], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/drive/root/children?$skip=1", + } + page2 = MagicMock() + page2.is_success = True + page2.json.return_value = {"value": [{"id": "i2", "name": "B.txt", "file": {}, "size": 20}]} + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.get = AsyncMock(side_effect=[page1, page2]) + mock_client_cls.return_value = mock_client + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + + assert result.complete is True + assert len(result.items) == 2 + + +class TestWebDAVListFolder: + """WebDAV list_folder tests — SSRF guard and no byte-download.""" + + def _make_backend(self): + from storage.webdav_backend import WebDAVBackend + from unittest.mock import MagicMock, patch + with patch("storage.webdav_backend.validate_cloud_url"): + with patch("webdav3.client.Client"): + backend = WebDAVBackend.__new__(WebDAVBackend) + backend._server_url = "https://dav.example.com/" + backend._client = MagicMock() + return backend + + @pytest.mark.asyncio + async def test_list_folder_returns_cloud_listing(self): + from storage.cloud_base import CloudListing + from unittest.mock import patch, MagicMock + import uuid + + backend = self._make_backend() + + def fake_list(path): + return ["file.txt", "subdir/"] + + def fake_info(path): + if "subdir" in path: + return {"isdir": True, "size": 0} + return {"isdir": False, "size": 512, "getcontenttype": "text/plain"} + + with patch("storage.webdav_backend.validate_cloud_url"): + backend._client.list.side_effect = fake_list + backend._client.info.side_effect = fake_info + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + + assert isinstance(result, CloudListing) + assert len(result.items) == 2 + + @pytest.mark.asyncio + async def test_list_folder_never_calls_get_object(self): + """list_folder must never invoke get_object.""" + from unittest.mock import patch, MagicMock + import uuid + + backend = self._make_backend() + + with patch("storage.webdav_backend.validate_cloud_url"): + backend._client.list.return_value = [] + with patch.object(backend, "get_object", side_effect=AssertionError("get_object called")) as mock_get: + await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + mock_get.assert_not_called() + + @pytest.mark.asyncio + async def test_list_folder_incomplete_on_error(self): + from unittest.mock import patch + import uuid + + backend = self._make_backend() + + with patch("storage.webdav_backend.validate_cloud_url"): + backend._client.list.side_effect = Exception("connection failed") + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + + assert result.complete is False + assert len(result.items) == 0 + + @pytest.mark.asyncio + async def test_list_folder_absent_props_treated_as_unknown(self): + """Items with missing PROPFIND fields are included with None values, not excluded.""" + from unittest.mock import patch + import uuid + + backend = self._make_backend() + + def fake_list(path): + return ["mystery.bin"] + + def fake_info(path): + return {} + + with patch("storage.webdav_backend.validate_cloud_url"): + backend._client.list.side_effect = fake_list + backend._client.info.side_effect = fake_info + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + + assert len(result.items) == 1 + item = result.items[0] + assert item.name == "mystery.bin" + + @pytest.mark.asyncio + async def test_get_capabilities_returns_all_actions(self): + from storage.cloud_base import ACTIONS + from unittest.mock import patch + import uuid + + backend = self._make_backend() + + with patch("storage.webdav_backend.validate_cloud_url"): + backend._client.check.return_value = True + caps = await backend.get_capabilities(uuid.uuid4(), uuid.uuid4()) + + assert set(caps.keys()) == ACTIONS + assert caps["browse"].state == "supported"