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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user