""" 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 .""" 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 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"