diff --git a/backend/tests/test_cloud_backends.py b/backend/tests/test_cloud_backends.py index 129f2de..2596e84 100644 --- a/backend/tests/test_cloud_backends.py +++ b/backend/tests/test_cloud_backends.py @@ -25,6 +25,8 @@ from __future__ import annotations import asyncio import inspect +import uuid as _uuid +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -795,3 +797,683 @@ class TestWebDAVListFolder: assert set(caps.keys()) == ACTIONS assert caps["browse"].state == "supported" + + +# ============================================================================= +# Phase 13 Plan 01 — RED: Provider-specific mutable-operation contracts +# +# All tests below FAIL until Phase 13 implements the mutable adapter interface. +# ============================================================================= + + +class TestGoogleDriveMutableContract: + """Google Drive — Phase 13 mutable-operation contract (RED). + + D-17: Phase 13 requests broader drive.files scope — test must assert + the consent scope includes 'drive' or 'drive.file' as the minimum for + listing and operating on pre-existing Drive items. + + All method assertions will fail until MutableCloudResourceAdapter is + implemented in Phase 13. + """ + + def _make_backend(self): + from storage.google_drive_backend import GoogleDriveBackend + return GoogleDriveBackend({ + "access_token": "ya29.test_13", + "refresh_token": "1//test_refresh_13", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "test_client_13", + "client_secret": "test_secret_13", + }) + + def test_create_folder_is_async(self): + """create_folder must be an async coroutine on GoogleDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.google_drive_backend import GoogleDriveBackend + assert hasattr(GoogleDriveBackend, "create_folder"), ( + "GoogleDriveBackend must implement create_folder (Phase 13)" + ) + assert inspect.iscoroutinefunction(GoogleDriveBackend.create_folder), ( + "create_folder must be async" + ) + + def test_rename_is_async(self): + """rename must be an async coroutine on GoogleDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.google_drive_backend import GoogleDriveBackend + assert hasattr(GoogleDriveBackend, "rename"), ( + "GoogleDriveBackend must implement rename (Phase 13)" + ) + assert inspect.iscoroutinefunction(GoogleDriveBackend.rename) + + def test_move_is_async(self): + """move must be an async coroutine on GoogleDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.google_drive_backend import GoogleDriveBackend + assert hasattr(GoogleDriveBackend, "move"), ( + "GoogleDriveBackend must implement move (Phase 13)" + ) + assert inspect.iscoroutinefunction(GoogleDriveBackend.move) + + def test_delete_is_async(self): + """delete must be an async coroutine on GoogleDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.google_drive_backend import GoogleDriveBackend + assert hasattr(GoogleDriveBackend, "delete"), ( + "GoogleDriveBackend must implement delete (Phase 13)" + ) + assert inspect.iscoroutinefunction(GoogleDriveBackend.delete) + + def test_upload_file_is_async(self): + """upload_file must be an async coroutine on GoogleDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.google_drive_backend import GoogleDriveBackend + assert hasattr(GoogleDriveBackend, "upload_file"), ( + "GoogleDriveBackend must implement upload_file (Phase 13)" + ) + assert inspect.iscoroutinefunction(GoogleDriveBackend.upload_file) + + @pytest.mark.asyncio + async def test_delete_prefers_trash_normalizes_result(self): + """Google Drive delete should prefer Trash and return typed result dict. + + D-11: Provider trash is preferred over permanent delete. The normalized + result must carry {'kind': 'deleted', 'reason': 'trashed'} or 'permanent'. + + FAILS: delete method does not exist yet. + """ + from storage.google_drive_backend import GoogleDriveBackend + + backend = self._make_backend() + fake_service = MagicMock() + fake_service.files().trash().execute.return_value = {} + backend._get_service = MagicMock(return_value=fake_service) + + result = await backend.delete("file_id_123", trash=True) + assert isinstance(result, dict), "delete must return a normalized dict result" + assert result.get("kind") == "deleted", ( + f"Expected kind='deleted', got {result.get('kind')!r}" + ) + assert result.get("reason") in ("trashed", "permanent"), ( + f"Expected reason 'trashed' or 'permanent', got {result.get('reason')!r}" + ) + + @pytest.mark.asyncio + async def test_create_folder_name_collision_normalizes_result(self): + """create_folder with a collision returns normalized conflict kind/reason. + + D-05: Create-folder collision must return {'kind': 'conflict', 'reason': 'name_collision'} + rather than raising an unhandled API error. + + FAILS: create_folder method does not exist yet. + """ + from storage.google_drive_backend import GoogleDriveBackend + + backend = self._make_backend() + fake_service = MagicMock() + # Simulate Drive returning a 409 or duplicate error + from googleapiclient.errors import HttpError + fake_resp = MagicMock() + fake_resp.status = 409 + fake_service.files().create().execute.side_effect = HttpError( + resp=fake_resp, content=b'{"error":{"message":"File already exists"}}' + ) + backend._get_service = MagicMock(return_value=fake_service) + + result = await backend.create_folder( + parent_ref=None, + name="Existing Folder", + connection_id=_uuid.uuid4(), + user_id=_uuid.uuid4(), + ) + assert isinstance(result, dict), "create_folder must return a normalized dict" + assert result.get("kind") == "conflict", ( + f"Expected kind='conflict', got {result.get('kind')!r}" + ) + + @pytest.mark.asyncio + async def test_rename_stale_metadata_normalizes_result(self): + """rename with mismatched etag returns normalized stale kind/reason. + + D-07: Stale rename must return {'kind': 'stale', 'reason': 'item_changed'}. + + FAILS: rename method does not exist yet. + """ + from storage.google_drive_backend import GoogleDriveBackend + + backend = self._make_backend() + fake_service = MagicMock() + from googleapiclient.errors import HttpError + fake_resp = MagicMock() + fake_resp.status = 412 + fake_service.files().update().execute.side_effect = HttpError( + resp=fake_resp, content=b'{"error":{"message":"precondition failed"}}' + ) + backend._get_service = MagicMock(return_value=fake_service) + + result = await backend.rename( + provider_item_id="file_id_123", + new_name="Renamed.pdf", + etag="old-etag", + ) + assert isinstance(result, dict), "rename must return a normalized dict" + assert result.get("kind") == "stale", ( + f"Expected kind='stale', got {result.get('kind')!r}" + ) + + def test_drive_scope_includes_full_drive_access(self): + """Google Drive backend must request 'drive' or 'drive.file' scope (D-17). + + D-17: Phase 13 requests broader Google Drive access — 'drive.file' scope only + allows access to files created by the app. Full drive browsing requires 'drive'. + + FAILS: Phase 13 scope expansion not yet implemented. + """ + import inspect + import re + from storage.google_drive_backend import GoogleDriveBackend + + # The backend's OAuth scope configuration must include full drive access + # Check for SCOPES constant or _SCOPES attribute + scopes = getattr(GoogleDriveBackend, "SCOPES", None) or \ + getattr(GoogleDriveBackend, "_SCOPES", None) or \ + getattr(GoogleDriveBackend, "OAUTH_SCOPES", None) + + assert scopes is not None, ( + "GoogleDriveBackend must declare a SCOPES / _SCOPES / OAUTH_SCOPES class attribute " + "for the OAuth consent scope (D-17)" + ) + scope_str = " ".join(scopes) if isinstance(scopes, (list, tuple)) else str(scopes) + # Phase 13 requires at minimum 'drive' scope (not just 'drive.file' or 'drive.readonly') + assert "https://www.googleapis.com/auth/drive" in scope_str or "drive" in scope_str, ( + f"GoogleDriveBackend SCOPES must include full Drive access for Phase 13 (D-17), " + f"got: {scope_str!r}" + ) + + +class TestOneDriveMutableContract: + """OneDrive — Phase 13 mutable-operation contract (RED). + + CONN-02: Successful token refresh must hand the new access_token and + refresh_token off to the service layer for encrypted persistence. + + All method assertions will fail until Phase 13 implements the mutable interface. + """ + + def _make_backend(self, expires_at: str = "2099-01-01T00:00:00"): + from storage.onedrive_backend import OneDriveBackend + return OneDriveBackend({ + "access_token": "od_access_13", + "refresh_token": "od_refresh_13", + "expires_at": expires_at, + }) + + def test_create_folder_is_async(self): + """create_folder must be an async coroutine on OneDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.onedrive_backend import OneDriveBackend + assert hasattr(OneDriveBackend, "create_folder"), ( + "OneDriveBackend must implement create_folder (Phase 13)" + ) + assert inspect.iscoroutinefunction(OneDriveBackend.create_folder) + + def test_rename_is_async(self): + """rename must be an async coroutine on OneDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.onedrive_backend import OneDriveBackend + assert hasattr(OneDriveBackend, "rename"), ( + "OneDriveBackend must implement rename (Phase 13)" + ) + assert inspect.iscoroutinefunction(OneDriveBackend.rename) + + def test_move_is_async(self): + """move must be an async coroutine on OneDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.onedrive_backend import OneDriveBackend + assert hasattr(OneDriveBackend, "move"), ( + "OneDriveBackend must implement move (Phase 13)" + ) + assert inspect.iscoroutinefunction(OneDriveBackend.move) + + def test_delete_is_async(self): + """delete must be an async coroutine on OneDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.onedrive_backend import OneDriveBackend + assert hasattr(OneDriveBackend, "delete"), ( + "OneDriveBackend must implement delete (Phase 13)" + ) + assert inspect.iscoroutinefunction(OneDriveBackend.delete) + + def test_upload_file_is_async(self): + """upload_file must be an async coroutine on OneDriveBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.onedrive_backend import OneDriveBackend + assert hasattr(OneDriveBackend, "upload_file"), ( + "OneDriveBackend must implement upload_file (Phase 13)" + ) + assert inspect.iscoroutinefunction(OneDriveBackend.upload_file) + + @pytest.mark.asyncio + async def test_refresh_token_hands_off_new_credentials(self): + """Successful token refresh returns new credentials for service-layer persistence. + + CONN-02: _refresh_token must return the new credentials dict (or signal the + service layer) so encrypted persistence can happen. The adapter itself must + not encrypt or store — that is the service layer's responsibility. + + FAILS: _refresh_token return contract not yet established. + """ + from storage.onedrive_backend import OneDriveBackend + + backend = self._make_backend(expires_at="2000-01-01T00:00:00") # expired + + new_access = "new_access_token_13" + new_refresh = "new_refresh_token_13" + + import msal + fake_app = MagicMock() + fake_app.acquire_token_by_refresh_token.return_value = { + "access_token": new_access, + "refresh_token": new_refresh, + "expires_in": 3600, + } + + with patch("msal.PublicClientApplication", return_value=fake_app): + refreshed = await backend._refresh_token() + + # _refresh_token must return the new credentials dict for service-layer handoff + assert refreshed is not None, ( + "_refresh_token must return new credentials dict for CONN-02 persistence handoff" + ) + assert isinstance(refreshed, dict), ( + "_refresh_token must return a dict of new credentials" + ) + assert refreshed.get("access_token") == new_access, ( + f"Refreshed access_token mismatch: expected {new_access!r}, got {refreshed.get('access_token')!r}" + ) + + @pytest.mark.asyncio + async def test_delete_permanent_normalizes_result(self): + """OneDrive delete returns normalized {'kind': 'deleted', 'reason': 'permanent'}. + + D-11: OneDrive does not have a REST trash API — deletes are permanent. + The result must make this explicit to the service layer / frontend. + + FAILS: delete method does not exist yet. + """ + from storage.onedrive_backend import OneDriveBackend + + backend = self._make_backend() + + mock_resp = MagicMock() + mock_resp.status_code = 204 + mock_resp.is_success = True + + with patch("httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.delete = AsyncMock(return_value=mock_resp) + mock_cls.return_value = mock_client + + result = await backend.delete("od_item_id_123") + + assert isinstance(result, dict), "delete must return a normalized dict" + assert result.get("kind") == "deleted" + assert result.get("reason") == "permanent", ( + "OneDrive deletes are permanent — reason must be 'permanent' (D-11)" + ) + + @pytest.mark.asyncio + async def test_nextlink_restricted_to_graph_host(self): + """OneDrive nextLink must only be followed to graph.microsoft.com host (SSRF guard). + + An SSRF attack could inject a nextLink pointing to an internal IP or + attacker-controlled server. The backend must restrict nextLink to the + known Graph API host. + + FAILS: Phase 13 nextLink host guard not yet implemented. + """ + from storage.onedrive_backend import OneDriveBackend + import uuid + + backend = self._make_backend() + + # Inject a malicious nextLink pointing to an attacker host + malicious_page1 = { + "value": [{"id": "od1", "name": "file.txt", "file": {}}], + "@odata.nextLink": "http://169.254.169.254/latest/meta-data/", + } + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = malicious_page1 + + with patch("httpx.AsyncClient") as mock_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_cls.return_value = mock_client + + result = await backend.list_folder(uuid.uuid4(), uuid.uuid4()) + + # The backend must NOT follow the malicious nextLink + # Result should be complete=False (truncated at the SSRF boundary) + # OR the listing should be returned without following the malicious page + assert mock_client.get.call_count >= 1 + # Verify the second call (if made) went only to graph.microsoft.com + if mock_client.get.call_count > 1: + for call_args in mock_client.get.call_args_list[1:]: + url = call_args[0][0] if call_args[0] else call_args[1].get("url", "") + assert "graph.microsoft.com" in url or "microsoftonline" in url, ( + f"nextLink follow must be restricted to Graph host — SSRF guard (T-13-04); " + f"got URL: {url!r}" + ) + + +class TestNextcloudMutableContract: + """Nextcloud — Phase 13 mutable-operation contract (RED). + + T-13-04: Nextcloud/WebDAV outbound calls must be SSRF-guarded. + All method assertions will fail until Phase 13 implements the mutable interface. + """ + + def _make_backend(self): + from storage.nextcloud_backend import NextcloudBackend + with patch("storage.webdav_backend.validate_cloud_url"): + with patch("webdav3.client.Client"): + backend = NextcloudBackend.__new__(NextcloudBackend) + backend._server_url = "https://nc.example.com/remote.php/dav/files/user/" + backend._username = "testuser" + backend._client = MagicMock() + return backend + + def test_create_folder_is_async(self): + """create_folder must be an async coroutine on NextcloudBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.nextcloud_backend import NextcloudBackend + assert hasattr(NextcloudBackend, "create_folder"), ( + "NextcloudBackend must implement create_folder (Phase 13)" + ) + assert inspect.iscoroutinefunction(NextcloudBackend.create_folder) + + def test_rename_is_async(self): + """rename must be an async coroutine on NextcloudBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.nextcloud_backend import NextcloudBackend + assert hasattr(NextcloudBackend, "rename"), ( + "NextcloudBackend must implement rename (Phase 13)" + ) + assert inspect.iscoroutinefunction(NextcloudBackend.rename) + + def test_move_is_async(self): + """move must be an async coroutine on NextcloudBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.nextcloud_backend import NextcloudBackend + assert hasattr(NextcloudBackend, "move"), ( + "NextcloudBackend must implement move (Phase 13)" + ) + assert inspect.iscoroutinefunction(NextcloudBackend.move) + + def test_delete_is_async(self): + """delete must be an async coroutine on NextcloudBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.nextcloud_backend import NextcloudBackend + assert hasattr(NextcloudBackend, "delete"), ( + "NextcloudBackend must implement delete (Phase 13)" + ) + assert inspect.iscoroutinefunction(NextcloudBackend.delete) + + def test_upload_file_is_async(self): + """upload_file must be an async coroutine on NextcloudBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.nextcloud_backend import NextcloudBackend + assert hasattr(NextcloudBackend, "upload_file"), ( + "NextcloudBackend must implement upload_file (Phase 13)" + ) + assert inspect.iscoroutinefunction(NextcloudBackend.upload_file) + + @pytest.mark.asyncio + async def test_create_folder_ssrf_guarded(self): + """create_folder must validate target path against SSRF redirect attempts (T-13-04). + + Nextcloud WebDAV create-folder (MKCOL) responses can include a Location header + pointing to an internal address. The backend must validate that any redirect + destination is within the configured server_url host. + + FAILS: create_folder SSRF guard does not exist yet. + """ + from storage.nextcloud_backend import NextcloudBackend + from storage.cloud_utils import validate_cloud_url + + backend = self._make_backend() + # The backend must reject any MKCOL path that tries to escape the server_url + backend._client.mkdir.side_effect = Exception("Internal redirect to http://169.254.169.254/") + + result = await backend.create_folder( + parent_ref=None, + name="../../etc", + connection_id=_uuid.uuid4(), + user_id=_uuid.uuid4(), + ) + assert isinstance(result, dict), "create_folder must return normalized dict even on error" + assert result.get("kind") in ("folder", "conflict", "error"), ( + f"Unexpected kind: {result.get('kind')!r}" + ) + + @pytest.mark.asyncio + async def test_delete_normalizes_result(self): + """NextcloudBackend delete returns normalized {'kind': 'deleted', 'reason': 'permanent'}. + + Nextcloud WebDAV does not expose a standard Trash API — deletes are permanent + unless the Trash plugin is active and specifically targeted. Default = permanent. + + FAILS: delete method does not exist yet. + """ + from storage.nextcloud_backend import NextcloudBackend + + backend = self._make_backend() + backend._client.clean.return_value = True + + result = await backend.delete("remote.php/dav/files/user/report.pdf") + assert isinstance(result, dict), "delete must return normalized dict" + assert result.get("kind") == "deleted" + assert result.get("reason") in ("permanent", "trashed"), ( + f"Expected 'permanent' or 'trashed', got {result.get('reason')!r}" + ) + + +class TestWebDAVMutableContract: + """WebDAV — Phase 13 mutable-operation contract (RED). + + T-13-04: WebDAV outbound SSRF guard must be preserved during mutations. + All method assertions will fail until Phase 13 implements the mutable interface. + """ + + def _make_backend(self): + from storage.webdav_backend import WebDAVBackend + with patch("storage.webdav_backend.validate_cloud_url"): + with patch("webdav3.client.Client"): + backend = WebDAVBackend.__new__(WebDAVBackend) + backend._server_url = "https://dav.example.com/dav/" + backend._client = MagicMock() + return backend + + def test_create_folder_is_async(self): + """create_folder must be an async coroutine on WebDAVBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.webdav_backend import WebDAVBackend + assert hasattr(WebDAVBackend, "create_folder"), ( + "WebDAVBackend must implement create_folder (Phase 13)" + ) + assert inspect.iscoroutinefunction(WebDAVBackend.create_folder) + + def test_rename_is_async(self): + """rename must be an async coroutine on WebDAVBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.webdav_backend import WebDAVBackend + assert hasattr(WebDAVBackend, "rename"), ( + "WebDAVBackend must implement rename (Phase 13)" + ) + assert inspect.iscoroutinefunction(WebDAVBackend.rename) + + def test_move_is_async(self): + """move must be an async coroutine on WebDAVBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.webdav_backend import WebDAVBackend + assert hasattr(WebDAVBackend, "move"), ( + "WebDAVBackend must implement move (Phase 13)" + ) + assert inspect.iscoroutinefunction(WebDAVBackend.move) + + def test_delete_is_async(self): + """delete must be an async coroutine on WebDAVBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.webdav_backend import WebDAVBackend + assert hasattr(WebDAVBackend, "delete"), ( + "WebDAVBackend must implement delete (Phase 13)" + ) + assert inspect.iscoroutinefunction(WebDAVBackend.delete) + + def test_upload_file_is_async(self): + """upload_file must be an async coroutine on WebDAVBackend (Phase 13). + + FAILS: method does not exist yet. + """ + import inspect + from storage.webdav_backend import WebDAVBackend + assert hasattr(WebDAVBackend, "upload_file"), ( + "WebDAVBackend must implement upload_file (Phase 13)" + ) + assert inspect.iscoroutinefunction(WebDAVBackend.upload_file) + + @pytest.mark.asyncio + async def test_delete_permanent_normalizes_result(self): + """WebDAVBackend delete returns normalized {'kind': 'deleted', 'reason': 'permanent'}. + + D-11: Generic WebDAV has no trash API — deletes are permanent. + The confirmation dialog must say so explicitly. + + FAILS: delete method does not exist yet. + """ + from storage.webdav_backend import WebDAVBackend + + backend = self._make_backend() + backend._client.clean.return_value = True + + result = await backend.delete("/dav/report.pdf") + assert isinstance(result, dict), "delete must return normalized dict" + assert result.get("kind") == "deleted" + assert result.get("reason") == "permanent", ( + "WebDAV deletes are permanent — reason must be 'permanent' (D-11)" + ) + + @pytest.mark.asyncio + async def test_move_validates_destination_stays_on_same_host(self): + """WebDAV MOVE destination must be validated to stay on the configured server host. + + T-13-04: A WebDAV MOVE request accepts a Destination header. The backend must + ensure the destination URL cannot escape to a different host (SSRF via Destination). + + FAILS: move SSRF guard does not exist yet. + """ + from storage.webdav_backend import WebDAVBackend + + backend = self._make_backend() + + # Attempt to move to a destination on a different host + try: + result = await backend.move( + provider_item_id="report.pdf", + destination_parent_ref="http://169.254.169.254/evil/", + etag="v1", + ) + # If it returns (not raises), it must produce a typed error result + assert isinstance(result, dict) + assert result.get("kind") == "invalid_destination", ( + f"Expected kind='invalid_destination' for SSRF destination, got {result.get('kind')!r}" + ) + except (ValueError, Exception) as exc: + # Raising an exception with the SSRF block is also acceptable + assert "SSRF" in str(exc) or "host" in str(exc).lower() or "invalid" in str(exc).lower(), ( + f"SSRF move rejection must mention 'SSRF', 'host', or 'invalid', got: {exc}" + ) + + def test_no_direct_cloud_items_writes(self): + """WebDAVBackend mutation methods must not import or write cloud_items directly. + + Provider-neutral contract: adapters must never write to cloud_items. Only the + service layer (services.cloud_items) is allowed to reconcile metadata. + + FAILS: Phase 13 implementation not yet done — this test will pass once + we verify the import graph doesn't include cloud_items in the backend module. + """ + import importlib + import sys + # Load the module source to check imports + spec = importlib.util.find_spec("storage.webdav_backend") + if spec and spec.origin: + with open(spec.origin) as f: + source = f.read() + assert "from services.cloud_items" not in source and "import cloud_items" not in source, ( + "WebDAVBackend must not import from services.cloud_items — " + "only the service layer writes cloud metadata (provider-neutral contract)" + ) diff --git a/backend/tests/test_cloud_provider_contract.py b/backend/tests/test_cloud_provider_contract.py index c4fe887..3c317cc 100644 --- a/backend/tests/test_cloud_provider_contract.py +++ b/backend/tests/test_cloud_provider_contract.py @@ -823,3 +823,299 @@ class TestOneDriveSpecificContract: assert item.kind == "file" # content_type may be None when 'file' key has no mimeType assert item.content_type is None or isinstance(item.content_type, str) + + +# ============================================================================= +# Phase 13 Plan 01 — RED: Four-provider mutable-operation parity contract +# +# All tests below FAIL until Phase 13 adds MutableCloudResourceAdapter. +# ============================================================================= + +# ── Phase 13 mutable provider cases ────────────────────────────────────────── + +MUTABLE_PROVIDER_CASES = [ + pytest.param("nextcloud", _make_nextcloud_adapter, id="nextcloud"), + pytest.param("webdav", _make_webdav_adapter, id="webdav"), + pytest.param("google_drive", _make_google_drive_adapter, id="google_drive"), + pytest.param("onedrive", _make_onedrive_adapter, id="onedrive"), +] + +# ── Mutable method signatures — canonical contract ──────────────────────────── + +MUTABLE_METHODS = [ + "create_folder", + "rename", + "move", + "delete", + "upload_file", +] + + +class TestMutableAdapterContract: + """ + Phase 13: Four-provider mutable-operation parity. + + Each provider must implement the same mutable-adapter method signatures. + The canonical contract is provider-neutral: + - No direct cloud_items writes in any adapter. + - No browse-time byte transfer. + - No hidden overwrite path. + - Result types use normalized kind/reason dicts. + - Caller identity (connection_id, user_id) flows through every method. + + All tests FAIL until Phase 13 adds the mutable adapter contract. + """ + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_mutable_methods_exist(self, provider, factory_fn): + """All Phase 13 mutable methods must exist on every provider adapter. + + FAILS: Phase 13 adapter methods not yet implemented. + """ + adapter = factory_fn() + for method_name in MUTABLE_METHODS: + assert hasattr(adapter, method_name), ( + f"{provider}: missing Phase 13 method '{method_name}' — " + "all four providers must implement the mutable adapter contract" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_mutable_methods_are_async(self, provider, factory_fn): + """All Phase 13 mutable methods must be async coroutines. + + FAILS: Phase 13 adapter methods not yet implemented. + """ + adapter = factory_fn() + for method_name in MUTABLE_METHODS: + method = getattr(type(adapter), method_name, None) + if method is None: + pytest.fail(f"{provider}: {method_name} not found — implement Phase 13 contract") + assert inspect.iscoroutinefunction(method), ( + f"{provider}: {method_name} must be defined with 'async def'" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_create_folder_signature(self, provider, factory_fn): + """create_folder must accept (parent_ref, name, connection_id, user_id). + + Provider-neutral contract: caller identity must be available to the method + for audit trail and reconciliation handoff. No provider-specific extra kwargs. + + FAILS: Phase 13 adapter methods not yet implemented. + """ + adapter = factory_fn() + assert hasattr(adapter, "create_folder"), ( + f"{provider}: create_folder not found" + ) + sig = inspect.signature(adapter.create_folder) + params = list(sig.parameters.keys()) + for required in ("parent_ref", "name", "connection_id", "user_id"): + assert required in params, ( + f"{provider}: create_folder missing required param '{required}'; " + f"got params: {params}" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_rename_signature(self, provider, factory_fn): + """rename must accept (provider_item_id, new_name, etag). + + etag is required for stale-metadata detection (D-07). + + FAILS: Phase 13 adapter methods not yet implemented. + """ + adapter = factory_fn() + assert hasattr(adapter, "rename"), f"{provider}: rename not found" + sig = inspect.signature(adapter.rename) + params = list(sig.parameters.keys()) + for required in ("provider_item_id", "new_name", "etag"): + assert required in params, ( + f"{provider}: rename missing required param '{required}'; got: {params}" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_move_signature(self, provider, factory_fn): + """move must accept (provider_item_id, destination_parent_ref, etag). + + etag ensures moves are not applied to stale metadata (D-07, D-08). + + FAILS: Phase 13 adapter methods not yet implemented. + """ + adapter = factory_fn() + assert hasattr(adapter, "move"), f"{provider}: move not found" + sig = inspect.signature(adapter.move) + params = list(sig.parameters.keys()) + for required in ("provider_item_id", "destination_parent_ref", "etag"): + assert required in params, ( + f"{provider}: move missing required param '{required}'; got: {params}" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_delete_signature(self, provider, factory_fn): + """delete must accept (provider_item_id) with optional trash kwarg. + + D-11: Provider trash/recycle-bin preference must be expressible by the caller. + The default should be trash=True (prefer trash when supported). + + FAILS: Phase 13 adapter methods not yet implemented. + """ + adapter = factory_fn() + assert hasattr(adapter, "delete"), f"{provider}: delete not found" + sig = inspect.signature(adapter.delete) + params = list(sig.parameters.keys()) + assert "provider_item_id" in params, ( + f"{provider}: delete missing 'provider_item_id'; got: {params}" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_upload_file_signature(self, provider, factory_fn): + """upload_file must accept (parent_ref, filename, content, content_type, connection_id, user_id). + + connection_id and user_id are required for audit trail and reconciliation. + + FAILS: Phase 13 adapter methods not yet implemented. + """ + adapter = factory_fn() + assert hasattr(adapter, "upload_file"), f"{provider}: upload_file not found" + sig = inspect.signature(adapter.upload_file) + params = list(sig.parameters.keys()) + for required in ("parent_ref", "filename", "connection_id", "user_id"): + assert required in params, ( + f"{provider}: upload_file missing required param '{required}'; got: {params}" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_no_direct_cloud_items_imports(self, provider, factory_fn): + """Provider adapter modules must not import from services.cloud_items. + + Provider-neutral contract: adapters must never write cloud metadata directly. + Only the service layer is authorised to call reconcile_cloud_listing or + upsert_cloud_item. + + FAILS until Phase 13 implementation confirms the import boundary. + """ + import importlib + import sys + + # Map provider names to module paths + module_map = { + "nextcloud": "storage.nextcloud_backend", + "webdav": "storage.webdav_backend", + "google_drive": "storage.google_drive_backend", + "onedrive": "storage.onedrive_backend", + } + module_name = module_map.get(provider) + if not module_name: + pytest.skip(f"No module map for provider {provider!r}") + + spec = importlib.util.find_spec(module_name) + if spec and spec.origin: + with open(spec.origin) as f: + source = f.read() + assert "from services.cloud_items" not in source and \ + "import cloud_items" not in source, ( + f"{provider}: adapter module must not import from services.cloud_items — " + "only the service layer writes cloud metadata (provider-neutral contract)" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_no_browse_time_byte_transfer(self, provider, factory_fn): + """list_folder must not call upload_file, delete, rename, move, or create_folder. + + Provider-neutral contract: browse operations are read-only. Mutable methods + must never be invoked as a side effect of listing. + + This test passes on the existing list_folder implementations and must + remain green through Phase 13 implementation. + """ + adapter = factory_fn() + for mutable_method in MUTABLE_METHODS: + if hasattr(adapter, mutable_method): + method = getattr(adapter, mutable_method) + # Spy setup would need actual invocation; check this is a separate method + # (not aliased to list_folder) + assert method is not getattr(adapter, "list_folder", None), ( + f"{provider}: {mutable_method} must not be aliased to list_folder" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_unsupported_capability_is_disclosed(self, provider, factory_fn): + """get_capabilities must explicitly disclose which mutation actions are unsupported. + + Provider-neutral contract: structurally unsupported actions must appear + in the capabilities dict with state='unsupported' and a known reason code. + The adapter must not silently omit unsupported actions from capabilities. + + This test passes on the existing get_capabilities contract and must remain + green through Phase 13 Phase implementation. + """ + from storage.cloud_base import ( + CloudCapability, ACTIONS, STATE_SUPPORTED, + STATE_UNSUPPORTED, STATE_TEMPORARILY_UNAVAILABLE, + ) + + # Verify ACTIONS includes all Phase 13 mutation actions + expected_mutation_actions = {"create_folder", "rename", "move", "delete", "upload"} + for action in expected_mutation_actions: + assert action in ACTIONS, ( + f"cloud_base.ACTIONS must include mutation action '{action}' for Phase 13" + ) + + +class TestMutableAdapterResultNormalization: + """ + Phase 13 Plan 01 — RED: Normalized result type assertions for mutable operations. + + Every mutation method must return a dict with at minimum {'kind': ..., 'reason': ...}. + Callers must never parse raw provider error structures — normalization happens + inside the adapter, not in the router or service layer. + """ + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_normalized_result_type_contract(self, provider, factory_fn): + """Mutable operations must be documented to return normalized dicts. + + This test asserts the existence of mutable methods and that their docstrings + declare the return type, giving Phase 13 implementors a clear failing signal. + + FAILS: Phase 13 adapter methods not yet implemented. + """ + adapter = factory_fn() + for method_name in MUTABLE_METHODS: + assert hasattr(adapter, method_name), ( + f"{provider}: {method_name} must be implemented for Phase 13" + ) + method = getattr(adapter, method_name) + # Method must have a docstring (normalized result docs are required) + assert method.__doc__, ( + f"{provider}.{method_name} must have a docstring describing the normalized return type" + ) + + @pytest.mark.parametrize("provider,factory_fn", MUTABLE_PROVIDER_CASES) + def test_conflict_normalization_defined(self, provider, factory_fn): + """The adapter must have a way to normalize provider-specific conflict errors. + + Provider-neutral contract: conflict normalization must happen inside the adapter. + No router or service layer should pattern-match against raw provider error classes. + + FAILS: Phase 13 adapter methods not yet implemented. + """ + adapter = factory_fn() + # The adapter must expose a normalization helper or internal method for conflicts. + # Acceptable patterns: _normalize_error, _handle_conflict, _map_error, etc. + normalization_methods = [ + "_normalize_error", + "_handle_conflict", + "_map_error", + "_classify_error", + "_normalize_result", + ] + has_normalization = any(hasattr(adapter, m) for m in normalization_methods) + # Also acceptable: if the mutable methods themselves handle normalization via + # a documented try/except that returns typed dicts. This assertion gives a + # failing signal that prompts the implementor to add normalization. + assert has_normalization or any( + hasattr(adapter, m) for m in MUTABLE_METHODS + ), ( + f"{provider}: adapter must implement Phase 13 mutable methods with " + f"normalized conflict/error handling (no raw provider exceptions in response)" + )