feat(12-02): normalize all four providers into CloudResourceAdapter contract

- GoogleDriveBackend: list_folder with full pagination, native doc size=None, get_capabilities
- OneDriveBackend: list_folder with @odata.nextLink pagination, get_capabilities
- WebDAVBackend: list_folder via PROPFIND with SSRF re-validation, get_capabilities
- NextcloudBackend: inherits CloudResourceAdapter from WebDAVBackend
- build_cloud_resource_adapter() added to factory
- 22 new contract/behavior/pagination/no-byte-download tests (51 total pass)
This commit is contained in:
curo1305
2026-06-18 22:45:09 +02:00
parent 71ba0293a5
commit ff33439f0a
5 changed files with 844 additions and 3 deletions
+22
View File
@@ -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
+178 -1
View File
@@ -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
+141 -1
View File
@@ -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
+149 -1
View File
@@ -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
+354
View File
@@ -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"