Files
kite/backend/tests/test_cloud_backends.py
T
curo1305 805fe44bfb feat(12.1-01): add cross-origin nextLink guard and Drive/OneDrive contract tests
- Validate @odata.nextLink hostname against graph.microsoft.com before following
  (T-12.1-03); cross-origin nextLink returns complete=False with prior items retained
- Add TestOneDriveCrossOriginNextLink: cross-origin rejection, same-origin follows,
  page failure retains prior items
- Add TestGoogleDriveAuthFailureControl: 401 returns complete=False, page1+page2
  error retains page1 items
- All 163 four-provider contract tests pass
2026-06-22 08:12:39 +02:00

798 lines
32 KiB
Python

"""
Phase 5 Plan 03 — TDD RED tests for GoogleDriveBackend and OneDriveBackend.
These tests are written BEFORE the implementation files exist, to serve as the
RED phase of the TDD cycle. They verify:
1. GoogleDriveBackend:
- Importability of class and CloudConnectionError
- All 7 methods are async coroutines
- presigned_get_url raises NotImplementedError
- generate_presigned_put_url raises NotImplementedError
- CloudConnectionError has a reason attribute
- cache_discovery=False is used (prevents /tmp traversal — T-05-03-05)
2. OneDriveBackend:
- Importability of class
- All 7 methods are async coroutines
- CHUNK_SIZE = 10 * 1024 * 1024 (10 MB — Pitfall 6 prevention)
- presigned_get_url raises NotImplementedError
- generate_presigned_put_url raises NotImplementedError
- CloudConnectionError imported from google_drive_backend (shared exception type)
- _ensure_valid_token skips refresh when token is not expired (non-expired expires_at)
"""
from __future__ import annotations
import asyncio
import inspect
import pytest
# ── GoogleDriveBackend tests ──────────────────────────────────────────────────
class TestGoogleDriveBackendImport:
"""Verify the module and class can be imported."""
def test_google_drive_backend_import(self):
"""GoogleDriveBackend and CloudConnectionError are importable."""
from storage.google_drive_backend import GoogleDriveBackend, CloudConnectionError # noqa: F401
assert GoogleDriveBackend is not None
assert CloudConnectionError is not None
def test_cloud_connection_error_has_reason(self):
"""CloudConnectionError stores a reason attribute."""
from storage.google_drive_backend import CloudConnectionError
err = CloudConnectionError("test", reason="token_expired")
assert err.reason == "token_expired"
def test_cloud_connection_error_reason_invalid_grant(self):
"""CloudConnectionError stores reason='invalid_grant'."""
from storage.google_drive_backend import CloudConnectionError
err = CloudConnectionError("", reason="invalid_grant")
assert err.reason == "invalid_grant"
class TestGoogleDriveBackendMethods:
"""Verify all 7 StorageBackend methods are implemented as async coroutines."""
METHODS = [
"put_object",
"get_object",
"delete_object",
"presigned_get_url",
"health_check",
"generate_presigned_put_url",
"stat_object",
]
def _make_backend(self):
from storage.google_drive_backend import GoogleDriveBackend
return GoogleDriveBackend({
"access_token": "fake_token",
"refresh_token": "fake_refresh",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "fake_client_id",
"client_secret": "fake_client_secret",
})
@pytest.mark.parametrize("method_name", METHODS)
def test_method_is_coroutine(self, method_name):
"""Every StorageBackend method must be an async coroutine function."""
from storage.google_drive_backend import GoogleDriveBackend
method = getattr(GoogleDriveBackend, method_name)
assert inspect.iscoroutinefunction(method), (
f"{method_name} must be defined with 'async def'"
)
@pytest.mark.asyncio
async def test_presigned_get_url_raises_not_implemented(self):
"""presigned_get_url raises NotImplementedError (D-14 — cloud backends use get_object)."""
backend = self._make_backend()
with pytest.raises(NotImplementedError):
await backend.presigned_get_url("some_file_id")
@pytest.mark.asyncio
async def test_generate_presigned_put_url_raises_not_implemented(self):
"""generate_presigned_put_url raises NotImplementedError (D-14)."""
backend = self._make_backend()
with pytest.raises(NotImplementedError):
await backend.generate_presigned_put_url("some_file_id")
def test_inherits_storage_backend(self):
"""GoogleDriveBackend is a StorageBackend subclass."""
from storage.google_drive_backend import GoogleDriveBackend
from storage.base import StorageBackend
assert issubclass(GoogleDriveBackend, StorageBackend)
class TestGoogleDriveBackendInit:
"""Verify __init__ constructs a valid Credentials object from a dict."""
def test_init_with_valid_credentials_dict(self):
"""GoogleDriveBackend.__init__ accepts the credentials dict and creates _creds."""
from storage.google_drive_backend import GoogleDriveBackend
creds_dict = {
"access_token": "ya29.test",
"refresh_token": "1//test_refresh",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "test_client_id",
"client_secret": "test_client_secret",
"expiry": "2099-01-01T00:00:00",
}
backend = GoogleDriveBackend(creds_dict)
# Verify credentials object was created
assert backend._creds is not None
assert backend._creds.token == "ya29.test"
assert backend._creds.refresh_token == "1//test_refresh"
def test_init_stores_creds_dict(self):
"""GoogleDriveBackend stores the original credentials dict."""
from storage.google_drive_backend import GoogleDriveBackend
creds_dict = {
"access_token": "ya29.test",
"refresh_token": "1//test_refresh",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "test_client_id",
"client_secret": "test_client_secret",
}
backend = GoogleDriveBackend(creds_dict)
assert backend._creds_dict is creds_dict
# ── OneDriveBackend tests ─────────────────────────────────────────────────────
class TestOneDriveBackendImport:
"""Verify OneDriveBackend is importable and uses shared CloudConnectionError."""
def test_onedrive_backend_import(self):
"""OneDriveBackend is importable from storage.onedrive_backend."""
from storage.onedrive_backend import OneDriveBackend # noqa: F401
assert OneDriveBackend is not None
def test_chunk_size(self):
"""CHUNK_SIZE must be exactly 10 MB (Pitfall 6 — above Graph 4 MB limit)."""
from storage.onedrive_backend import CHUNK_SIZE
assert CHUNK_SIZE == 10 * 1024 * 1024, (
f"CHUNK_SIZE should be 10 * 1024 * 1024 (10 MB), got {CHUNK_SIZE}"
)
def test_cloud_connection_error_is_shared(self):
"""CloudConnectionError used in OneDriveBackend is the same class from google_drive_backend."""
from storage.google_drive_backend import CloudConnectionError as GDriveError
from storage.onedrive_backend import CloudConnectionError as OneDriveError
assert GDriveError is OneDriveError, (
"OneDriveBackend must import CloudConnectionError from google_drive_backend, "
"not define its own."
)
class TestOneDriveBackendMethods:
"""Verify all 7 StorageBackend methods are async on OneDriveBackend."""
METHODS = [
"put_object",
"get_object",
"delete_object",
"presigned_get_url",
"health_check",
"generate_presigned_put_url",
"stat_object",
]
def _make_backend(self, expires_at: str = "2099-01-01T00:00:00"):
from storage.onedrive_backend import OneDriveBackend
return OneDriveBackend({
"access_token": "fake_access",
"refresh_token": "fake_refresh",
"expires_at": expires_at,
})
@pytest.mark.parametrize("method_name", METHODS)
def test_method_is_coroutine(self, method_name):
"""Every StorageBackend method must be async."""
from storage.onedrive_backend import OneDriveBackend
method = getattr(OneDriveBackend, method_name)
assert inspect.iscoroutinefunction(method), (
f"{method_name} must be defined with 'async def'"
)
@pytest.mark.asyncio
async def test_presigned_get_url_raises_not_implemented(self):
"""presigned_get_url raises NotImplementedError (D-14)."""
backend = self._make_backend()
with pytest.raises(NotImplementedError):
await backend.presigned_get_url("item_id")
@pytest.mark.asyncio
async def test_generate_presigned_put_url_raises_not_implemented(self):
"""generate_presigned_put_url raises NotImplementedError (D-14)."""
backend = self._make_backend()
with pytest.raises(NotImplementedError):
await backend.generate_presigned_put_url("item_id")
def test_inherits_storage_backend(self):
"""OneDriveBackend is a StorageBackend subclass."""
from storage.onedrive_backend import OneDriveBackend
from storage.base import StorageBackend
assert issubclass(OneDriveBackend, StorageBackend)
class TestOneDriveBackendInit:
"""Verify OneDriveBackend __init__ stores credentials correctly."""
def test_init_stores_credentials(self):
"""OneDriveBackend stores the credentials dict as _credentials."""
from storage.onedrive_backend import OneDriveBackend
creds = {
"access_token": "Bearer test",
"refresh_token": "test_refresh",
"expires_at": "2099-01-01T00:00:00",
}
backend = OneDriveBackend(creds)
assert backend._credentials is creds
def test_auth_headers_contain_bearer(self):
"""_auth_headers() returns Authorization: Bearer <access_token>."""
from storage.onedrive_backend import OneDriveBackend
creds = {
"access_token": "my_token_123",
"refresh_token": "test_refresh",
"expires_at": "2099-01-01T00:00:00",
}
backend = OneDriveBackend(creds)
headers = backend._auth_headers()
assert headers["Authorization"] == "Bearer my_token_123"
class TestOneDriveEnsureValidToken:
"""Test _ensure_valid_token logic for expired / non-expired tokens."""
@pytest.mark.asyncio
async def test_valid_token_no_refresh_needed(self):
"""Non-expired token should not trigger a refresh."""
from storage.onedrive_backend import OneDriveBackend
backend = OneDriveBackend({
"access_token": "valid_token",
"refresh_token": "refresh",
"expires_at": "2099-01-01T00:00:00",
})
# Should complete without error (no msal call needed)
await backend._ensure_valid_token()
# Token should remain unchanged
assert backend._credentials["access_token"] == "valid_token"
@pytest.mark.asyncio
async def test_expired_token_raises_cloud_connection_error_on_invalid_grant(self):
"""Expired token with invalid_grant from MSAL raises CloudConnectionError(reason='invalid_grant')."""
from unittest.mock import patch, AsyncMock, MagicMock
from storage.onedrive_backend import OneDriveBackend
from storage.google_drive_backend import CloudConnectionError
backend = OneDriveBackend({
"access_token": "expired_token",
"refresh_token": "bad_refresh",
"expires_at": "2000-01-01T00:00:00", # clearly expired
})
# Mock _refresh_token to return None (simulating invalid_grant)
async def fake_refresh():
return None
with patch.object(backend, "_refresh_token", side_effect=fake_refresh):
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 TestOneDriveCrossOriginNextLink:
"""OneDrive @odata.nextLink must stay on graph.microsoft.com — T-12.1-03."""
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_cross_origin_next_link_returns_incomplete(self):
"""A cross-origin @odata.nextLink must stop pagination and return complete=False."""
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}],
# Cross-origin nextLink — must be rejected
"@odata.nextLink": "https://evil.example.com/v1.0/me/drive/root/children?$skip=1",
}
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=page1)
mock_client_cls.return_value = mock_client
result = await backend.list_folder(uuid.uuid4(), uuid.uuid4())
# Items from page1 should be retained
assert len(result.items) == 1
assert result.items[0].name == "A.txt"
# But listing is not complete (cross-origin continuation not trusted)
assert result.complete is False
@pytest.mark.asyncio
async def test_same_origin_graph_next_link_followed(self):
"""A same-origin graph.microsoft.com @odata.nextLink is trusted and followed."""
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
@pytest.mark.asyncio
async def test_page_failure_retains_prior_items(self):
"""A page HTTP failure returns complete=False but retains already-normalized items."""
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_fail = MagicMock()
page2_fail.is_success = False
page2_fail.status_code = 503
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_fail])
mock_client_cls.return_value = mock_client
result = await backend.list_folder(uuid.uuid4(), uuid.uuid4())
# Page1 items retained
assert len(result.items) == 1
assert result.items[0].name == "A.txt"
# Not complete because page2 failed
assert result.complete is False
class TestGoogleDriveAuthFailureControl:
"""Drive auth failures map to controlled complete=False — T-12.1-03."""
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_401_returns_incomplete_not_raises(self):
"""401 from Drive returns complete=False — not an uncaught exception."""
from unittest.mock import MagicMock, patch
from googleapiclient.errors import HttpError
import uuid
backend = self._make_backend()
fake_service = MagicMock()
resp = MagicMock()
resp.status = 401
fake_service.files().list().execute.side_effect = HttpError(resp, b"unauthorized")
with patch.object(backend, "_get_service", return_value=fake_service):
result = await backend.list_folder(uuid.uuid4(), uuid.uuid4())
assert result.complete is False
@pytest.mark.asyncio
async def test_page1_ok_page2_error_retains_items(self):
"""Error on page2 still returns items from page1 with complete=False."""
from unittest.mock import MagicMock, patch
from googleapiclient.errors import HttpError
import uuid
backend = self._make_backend()
fake_service = MagicMock()
resp403 = MagicMock()
resp403.status = 403
fake_service.files().list().execute.side_effect = [
{
"files": [{"id": "f1", "name": "a.pdf", "mimeType": "application/pdf", "size": "100"}],
"nextPageToken": "synth_tok",
},
HttpError(resp403, b"scope"),
]
with patch.object(backend, "_get_service", return_value=fake_service):
result = await backend.list_folder(uuid.uuid4(), uuid.uuid4())
# Items from page1 must be retained
assert len(result.items) == 1
assert result.items[0].name == "a.pdf"
assert result.complete is False
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"