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:
@@ -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