diff --git a/backend/tests/fixtures/cloud/google_drive_pages.json b/backend/tests/fixtures/cloud/google_drive_pages.json new file mode 100644 index 0000000..1249fa9 --- /dev/null +++ b/backend/tests/fixtures/cloud/google_drive_pages.json @@ -0,0 +1,90 @@ +{ + "_comment": "Synthetic Google Drive fixture — no real credentials, tokens, or user data.", + + "root_page1": { + "nextPageToken": "page2token_synthetic", + "files": [ + { + "id": "synth_folder_a1b2c3", + "name": "Projects", + "mimeType": "application/vnd.google-apps.folder", + "modifiedTime": "2024-06-10T12:00:00Z" + }, + { + "id": "synth_folder_d4e5f6", + "name": "Archive", + "mimeType": "application/vnd.google-apps.folder", + "modifiedTime": "2024-06-11T08:00:00Z" + }, + { + "id": "synth_file_g7h8i9", + "name": "report.pdf", + "mimeType": "application/pdf", + "size": "204800", + "modifiedTime": "2024-06-12T14:00:00Z", + "md5Checksum": "abc123synth" + } + ] + }, + + "root_page2": { + "files": [ + { + "id": "synth_file_j0k1l2", + "name": "notes.txt", + "mimeType": "text/plain", + "size": "1024", + "modifiedTime": "2024-06-13T10:00:00Z", + "md5Checksum": "def456synth" + }, + { + "id": "synth_native_m3n4o5", + "name": "Meeting Notes", + "mimeType": "application/vnd.google-apps.document", + "modifiedTime": "2024-06-14T09:30:00Z" + } + ] + }, + + "nested_folder": { + "files": [ + { + "id": "synth_nested_p6q7r8", + "name": "sub-document.docx", + "mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "size": "51200", + "modifiedTime": "2024-06-15T11:00:00Z" + } + ] + }, + + "trashed_excluded": { + "files": [ + { + "id": "synth_file_s9t0u1", + "name": "visible.pdf", + "mimeType": "application/pdf", + "size": "10240", + "modifiedTime": "2024-06-16T10:00:00Z" + } + ], + "_note": "trashed=false query param ensures trashed items are not returned" + }, + + "native_docs_nullable_size": { + "files": [ + { + "id": "synth_doc_v2w3x4", + "name": "Spreadsheet", + "mimeType": "application/vnd.google-apps.spreadsheet", + "modifiedTime": "2024-06-17T13:00:00Z" + }, + { + "id": "synth_slides_y5z6a7", + "name": "Presentation", + "mimeType": "application/vnd.google-apps.presentation", + "modifiedTime": "2024-06-18T14:00:00Z" + } + ] + } +} diff --git a/backend/tests/fixtures/cloud/nextcloud_root.xml b/backend/tests/fixtures/cloud/nextcloud_root.xml new file mode 100644 index 0000000..37c7440 --- /dev/null +++ b/backend/tests/fixtures/cloud/nextcloud_root.xml @@ -0,0 +1,164 @@ + + + + + + + /remote.php/dav/files/testuser/ + + + + testuser + Mon, 01 Jan 2024 00:00:00 GMT + + HTTP/1.1 200 OK + + + + + + /remote.php/dav/files/testuser/Documents/ + + + + Documents + Mon, 10 Jun 2024 12:00:00 GMT + 0 + + HTTP/1.1 200 OK + + + + + + /remote.php/dav/files/testuser/Photos/ + + + + Photos + Tue, 11 Jun 2024 08:30:00 GMT + + HTTP/1.1 200 OK + + + + + + /remote.php/dav/files/testuser/My%20Work%20Files/ + + + + My Work Files + Wed, 12 Jun 2024 09:00:00 GMT + + HTTP/1.1 200 OK + + + + + + /remote.php/dav/files/testuser/Caf%C3%A9%20Designs/ + + + + Café Designs + Thu, 13 Jun 2024 10:15:00 GMT + + HTTP/1.1 200 OK + + + + + + /remote.php/dav/files/testuser/report.pdf + + + + report.pdf + 204800 + application/pdf + Fri, 14 Jun 2024 14:00:00 GMT + "abc123etag" + + HTTP/1.1 200 OK + + + + + + /remote.php/dav/files/testuser/notes.txt + + + + notes.txt + 1024 + Sat, 15 Jun 2024 10:00:00 GMT + + HTTP/1.1 200 OK + + + + + + /remote.php/dav/files/testuser/My%20Report%20Draft.docx + + + + My Report Draft.docx + 51200 + application/vnd.openxmlformats-officedocument.wordprocessingml.document + Sun, 16 Jun 2024 11:00:00 GMT + "docx456etag" + + HTTP/1.1 200 OK + + + + + + /remote.php/dav/files/testuser/image.png + + + + image.png + image/png + Mon, 17 Jun 2024 12:30:00 GMT + + HTTP/1.1 200 OK + + + + + + /remote.php/dav/files/testuser/data.csv + + + + data.csv + + HTTP/1.1 200 OK + + + + + + remote.php/dav/files/testuser/archive.zip + + + + archive.zip + 102400 + application/zip + Tue, 18 Jun 2024 09:00:00 GMT + "zip789etag" + + HTTP/1.1 200 OK + + + + diff --git a/backend/tests/fixtures/cloud/onedrive_pages.json b/backend/tests/fixtures/cloud/onedrive_pages.json new file mode 100644 index 0000000..5b06f05 --- /dev/null +++ b/backend/tests/fixtures/cloud/onedrive_pages.json @@ -0,0 +1,93 @@ +{ + "_comment": "Synthetic OneDrive fixture — no real credentials, tokens, drive IDs, or user data.", + + "root_page1": { + "value": [ + { + "id": "synth_od_folder_a1b2", + "name": "Projects", + "folder": {"childCount": 3}, + "parentReference": {"driveId": "synth-drive-id", "id": "root"}, + "lastModifiedDateTime": "2024-06-10T12:00:00Z", + "eTag": "etag_synth_folder_a1b2" + }, + { + "id": "synth_od_folder_c3d4", + "name": "My Archive", + "folder": {"childCount": 5}, + "parentReference": {"driveId": "synth-drive-id", "id": "root"}, + "lastModifiedDateTime": "2024-06-11T08:00:00Z" + }, + { + "id": "synth_od_file_e5f6", + "name": "report.pdf", + "file": {"mimeType": "application/pdf"}, + "size": 204800, + "parentReference": {"driveId": "synth-drive-id", "id": "root"}, + "lastModifiedDateTime": "2024-06-12T14:00:00Z", + "eTag": "etag_synth_file_e5f6" + } + ], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/drive/root/children?$skip=3&$top=3" + }, + + "root_page2": { + "value": [ + { + "id": "synth_od_file_g7h8", + "name": "My Report Draft.docx", + "file": {"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, + "size": 51200, + "parentReference": {"driveId": "synth-drive-id", "id": "root"}, + "lastModifiedDateTime": "2024-06-13T10:00:00Z" + }, + { + "id": "synth_od_file_i9j0", + "name": "notes.txt", + "file": {"mimeType": "text/plain"}, + "size": 1024, + "parentReference": {"driveId": "synth-drive-id", "id": "root"}, + "lastModifiedDateTime": "2024-06-14T09:00:00Z", + "eTag": "etag_synth_file_i9j0" + } + ] + }, + + "nested_children": { + "value": [ + { + "id": "synth_od_file_k1l2", + "name": "sub-document.docx", + "file": {"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, + "size": 25600, + "parentReference": {"driveId": "synth-drive-id", "id": "synth_od_folder_a1b2"}, + "lastModifiedDateTime": "2024-06-15T11:00:00Z" + } + ] + }, + + "nullable_metadata": { + "value": [ + { + "id": "synth_od_file_m3n4", + "name": "unknown-file.bin", + "file": {}, + "parentReference": {"driveId": "synth-drive-id", "id": "root"}, + "lastModifiedDateTime": "2024-06-16T10:00:00Z" + } + ] + }, + + "encoded_names": { + "value": [ + { + "id": "synth_od_file_o5p6", + "name": "Annual Report & Summary.pdf", + "file": {"mimeType": "application/pdf"}, + "size": 102400, + "parentReference": {"driveId": "synth-drive-id", "id": "root"}, + "lastModifiedDateTime": "2024-06-17T13:00:00Z" + } + ] + } +} diff --git a/backend/tests/fixtures/cloud/webdav_root.xml b/backend/tests/fixtures/cloud/webdav_root.xml new file mode 100644 index 0000000..c7cfde4 --- /dev/null +++ b/backend/tests/fixtures/cloud/webdav_root.xml @@ -0,0 +1,105 @@ + + + + + + + /dav/ + + + + root + + HTTP/1.1 200 OK + + + + + + /dav/Archive/ + + + + Archive + Mon, 10 Jun 2024 08:00:00 GMT + + HTTP/1.1 200 OK + + + + + + dav/Shared/ + + + + Shared + Tue, 11 Jun 2024 09:00:00 GMT + + HTTP/1.1 200 OK + + + + + + /dav/readme.txt + + + + readme.txt + 512 + text/plain + Wed, 12 Jun 2024 10:00:00 GMT + "readmeetag" + + HTTP/1.1 200 OK + + + + + + /dav/backup.tar.gz + + + + backup.tar.gz + 10240 + + HTTP/1.1 200 OK + + + + + + /dav/project%20notes.pdf + + + + project notes.pdf + 204800 + application/pdf + Thu, 13 Jun 2024 11:00:00 GMT + + HTTP/1.1 200 OK + + + + + + /other-root/escape.txt + + + + escape.txt + 100 + + HTTP/1.1 200 OK + + + + diff --git a/backend/tests/test_cloud_provider_contract.py b/backend/tests/test_cloud_provider_contract.py new file mode 100644 index 0000000..c4fe887 --- /dev/null +++ b/backend/tests/test_cloud_provider_contract.py @@ -0,0 +1,825 @@ +""" +Phase 12.1 Plan 01 — Shared four-provider CloudResourceAdapter contract suite. + +Purpose +------- +One parametrized contract harness asserting the canonical behavior of every +CloudResourceAdapter implementation across Nextcloud, generic WebDAV, Google +Drive, and OneDrive. + +Contract assertions +------------------- +1. Adapter is a CloudResourceAdapter subclass. +2. list_folder matches the canonical positional/keyword signature: + (connection_id, user_id, parent_ref=None, page_token=None) +3. Invocation through the canonical signature does NOT raise TypeError. +4. Return type is CloudListing (never a list[dict] or any other shape). +5. Every resource carries the trusted caller connection_id and user_id. +6. provider_item_id is set (non-empty opaque reference). +7. kind is exactly "file" or "folder". +8. parent_ref in every resource matches the parent_ref argument. +9. Optional metadata (size, content_type, modified_at, etag) is None when + absent rather than a fabricated value. +10. Forbidden spies: get_object, put, put_object, delete_object, upload, + create_folder, rename, delete, move, copy methods are never called by + list_folder. + +TDD note: test_provider_list_folder_contract MUST FAIL against the current +Nextcloud override (wrong signature → TypeError on canonical invocation). +All other providers should pass from the start. +""" +from __future__ import annotations + +import inspect +import json +import uuid +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# ── Fixture paths ───────────────────────────────────────────────────────────── + +FIXTURES_DIR = Path(__file__).parent / "fixtures" / "cloud" +NEXTCLOUD_ROOT_XML = (FIXTURES_DIR / "nextcloud_root.xml").read_text() +WEBDAV_ROOT_XML = (FIXTURES_DIR / "webdav_root.xml").read_text() +_GD_FIXTURE = json.loads((FIXTURES_DIR / "google_drive_pages.json").read_text()) +_OD_FIXTURE = json.loads((FIXTURES_DIR / "onedrive_pages.json").read_text()) + + +# ── Provider factory helpers ────────────────────────────────────────────────── + +def _make_nextcloud_adapter(): + """Construct a NextcloudBackend bypassing SSRF validation.""" + from storage.nextcloud_backend import NextcloudBackend + from unittest.mock import MagicMock + with patch("storage.webdav_backend.validate_cloud_url"): + with patch("webdav3.client.Client"): + backend = NextcloudBackend.__new__(NextcloudBackend) + backend._server_url = "https://nc.synth.example.com/remote.php/dav/files/testuser/" + backend._username = "testuser" + backend._client = MagicMock() + return backend + + +def _make_webdav_adapter(): + """Construct a WebDAVBackend bypassing SSRF validation.""" + 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.synth.example.com/dav/" + backend._client = MagicMock() + return backend + + +def _make_google_drive_adapter(): + """Construct a GoogleDriveBackend (no network credentials needed for tests).""" + from storage.google_drive_backend import GoogleDriveBackend + return GoogleDriveBackend({ + "access_token": "synth_access_token", + "refresh_token": "synth_refresh_token", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "synth_client_id", + "client_secret": "synth_client_secret", + }) + + +def _make_onedrive_adapter(): + """Construct a OneDriveBackend (no network credentials needed for tests).""" + from storage.onedrive_backend import OneDriveBackend + return OneDriveBackend({ + "access_token": "synth_access_token", + "refresh_token": "synth_refresh_token", + "expires_at": "2099-01-01T00:00:00", + }) + + +# ── Stub responses ──────────────────────────────────────────────────────────── + +def _webdav_list_stub(items: list[tuple[str, bool, int]]): + """ + items: list of (name, is_dir, size) + Returns a side_effect function for client.list() and client.info(). + """ + def _list(path): + return [name for name, _, _ in items] + + def _info(item_path): + for name, is_dir, size in items: + if name.rstrip("/") in item_path or item_path.endswith(name.rstrip("/")): + return {"isdir": is_dir, "size": size} + return {} + + return _list, _info + + +# ── Provider case registry ──────────────────────────────────────────────────── + +# Each case: (provider_name, factory_fn, stub_setup_fn) +# stub_setup_fn(adapter, connection_id, user_id) → patches set up for a successful root listing + +def _stub_nextcloud(adapter, conn_id, user_id): + """ + Stub for Nextcloud canonical-signature list_folder. + Since the current Nextcloud override is incompatible, this stub + cannot help the existing implementation — the TypeError happens before + the stubs are ever reached. This is the expected RED behavior. + """ + # Minimal stubs for when the implementation is fixed: + adapter._client.list = MagicMock(return_value=["folder1/", "file.txt"]) + adapter._client.info = MagicMock(return_value={"isdir": False, "size": 512}) + + +def _stub_webdav(adapter, conn_id, user_id): + adapter._client.list = MagicMock(return_value=["subfolder/", "file.txt"]) + adapter._client.info = MagicMock(side_effect=lambda p: { + "isdir": p.endswith("/"), + "size": 0 if p.endswith("/") else 512, + }) + + +def _stub_google_drive(adapter, conn_id, user_id): + """Stub Google Drive service to return one folder + one file.""" + page1 = _GD_FIXTURE["root_page1"].copy() + del page1["nextPageToken"] # single page for basic contract test + page1["files"] = page1["files"][:2] # folder + file + fake_service = MagicMock() + fake_service.files().list().execute.return_value = page1 + adapter._get_service = MagicMock(return_value=fake_service) + + +def _stub_onedrive(adapter, conn_id, user_id): + """Stub OneDrive http client to return one folder + one file.""" + page1 = { + "value": _OD_FIXTURE["root_page1"]["value"][:2], # folder + file + } + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = page1 + return mock_resp # caller uses this in httpx.AsyncClient patch + + +PROVIDER_CASES = [ + pytest.param("nextcloud", _make_nextcloud_adapter, _stub_nextcloud, id="nextcloud"), + pytest.param("webdav", _make_webdav_adapter, _stub_webdav, id="webdav"), + pytest.param("google_drive", _make_google_drive_adapter, _stub_google_drive, id="google_drive"), + pytest.param("onedrive", _make_onedrive_adapter, _stub_onedrive, id="onedrive"), +] + + +# ── T-12.1-04: Forbidden-operation spies ───────────────────────────────────── + +FORBIDDEN_METHODS = [ + "get_object", + "put_object", + "delete_object", + "generate_presigned_put_url", + "presigned_get_url", +] + +# Provider-specific forbidden method names (class-level or attribute names) +_DRIVE_FORBIDDEN = ["get_media"] +_ONEDRIVE_FORBIDDEN = [] # /content checked via URL in separate test + + +# ── Static contract tests (no provider invocation needed) ───────────────────── + +class TestAdapterIsCloudResourceAdapter: + """Assert all four provider classes subclass CloudResourceAdapter.""" + + @pytest.mark.parametrize("provider,factory_fn,_", PROVIDER_CASES) + def test_adapter_subclass(self, provider, factory_fn, _): + from storage.cloud_base import CloudResourceAdapter + adapter = factory_fn() + assert isinstance(adapter, CloudResourceAdapter), ( + f"{provider} adapter does not implement CloudResourceAdapter" + ) + + +class TestListFolderSignature: + """Assert list_folder signature matches the canonical contract.""" + + @pytest.mark.parametrize("provider,factory_fn,_", PROVIDER_CASES) + def test_canonical_signature(self, provider, factory_fn, _): + """list_folder must accept (connection_id, user_id, parent_ref=None, page_token=None).""" + adapter = factory_fn() + sig = inspect.signature(adapter.list_folder) + params = list(sig.parameters.keys()) + # self is not included in the signature of a bound method + assert "connection_id" in params, f"{provider}: missing connection_id param" + assert "user_id" in params, f"{provider}: missing user_id param" + assert "parent_ref" in params, f"{provider}: missing parent_ref param" + assert "page_token" in params, f"{provider}: missing page_token param" + # parent_ref and page_token must have defaults + assert sig.parameters["parent_ref"].default is None, ( + f"{provider}: parent_ref default must be None" + ) + assert sig.parameters["page_token"].default is None, ( + f"{provider}: page_token default must be None" + ) + + @pytest.mark.parametrize("provider,factory_fn,_", PROVIDER_CASES) + def test_list_folder_is_async(self, provider, factory_fn, _): + """list_folder must be an async coroutine function.""" + assert inspect.iscoroutinefunction(factory_fn().list_folder), ( + f"{provider}: list_folder must be defined with 'async def'" + ) + + +# ── Dynamic contract tests (invoke list_folder through canonical signature) ─── + +class TestProviderListFolderContract: + """ + Core behavioral contract: canonical invocation, return type, resource identity. + + test_provider_list_folder_contract MUST FAIL for nextcloud before Task 2 + repairs the incompatible override (TypeError on positional connection_id argument). + """ + + @pytest.mark.asyncio + @pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES) + async def test_provider_list_folder_contract(self, provider, factory_fn, stub_fn): + """Canonical invocation (connection_id, user_id, parent_ref=None) returns CloudListing.""" + from storage.cloud_base import CloudListing + adapter = factory_fn() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + if provider == "nextcloud": + with patch("storage.webdav_backend.validate_cloud_url"): + stub_fn(adapter, conn_id, user_id) + # This MUST raise TypeError with the current broken Nextcloud override. + # After Task 2 it must not raise and must return CloudListing. + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "webdav": + with patch("storage.webdav_backend.validate_cloud_url"): + stub_fn(adapter, conn_id, user_id) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "google_drive": + stub_fn(adapter, conn_id, user_id) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "onedrive": + mock_resp = stub_fn(adapter, conn_id, user_id) + 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 adapter.list_folder(conn_id, user_id, parent_ref=None) + + assert isinstance(result, CloudListing), ( + f"{provider}: list_folder must return CloudListing, " + f"got {type(result).__name__!r}" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES) + async def test_provider_root_and_nested_identity(self, provider, factory_fn, stub_fn): + """Every resource carries trusted caller connection_id and user_id.""" + from storage.cloud_base import CloudListing + adapter = factory_fn() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + if provider == "nextcloud": + with patch("storage.webdav_backend.validate_cloud_url"): + stub_fn(adapter, conn_id, user_id) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "webdav": + with patch("storage.webdav_backend.validate_cloud_url"): + stub_fn(adapter, conn_id, user_id) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "google_drive": + stub_fn(adapter, conn_id, user_id) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "onedrive": + mock_resp = stub_fn(adapter, conn_id, user_id) + 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 adapter.list_folder(conn_id, user_id, parent_ref=None) + + assert isinstance(result, CloudListing) + for item in result.items: + assert item.connection_id == conn_id, ( + f"{provider}: resource.connection_id must equal trusted caller; " + f"got {item.connection_id!r}" + ) + assert item.user_id == user_id, ( + f"{provider}: resource.user_id must equal trusted caller; " + f"got {item.user_id!r}" + ) + assert item.provider_item_id, ( + f"{provider}: provider_item_id must be non-empty" + ) + assert item.kind in ("file", "folder"), ( + f"{provider}: kind must be 'file' or 'folder', got {item.kind!r}" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES) + async def test_provider_metadata_normalization(self, provider, factory_fn, stub_fn): + """Absent optional metadata is None — not a fabricated default.""" + from storage.cloud_base import CloudListing + adapter = factory_fn() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + if provider == "nextcloud": + # Use a stub that returns items with deliberately absent optional props + with patch("storage.webdav_backend.validate_cloud_url"): + adapter._client.list = MagicMock(return_value=["mystery.bin"]) + adapter._client.info = MagicMock(return_value={}) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "webdav": + with patch("storage.webdav_backend.validate_cloud_url"): + adapter._client.list = MagicMock(return_value=["mystery.bin"]) + adapter._client.info = MagicMock(return_value={}) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "google_drive": + fake_service = MagicMock() + fake_service.files().list().execute.return_value = { + "files": [{"id": "synth_xyz", "name": "noprop.txt", "mimeType": "text/plain"}] + } + adapter._get_service = MagicMock(return_value=fake_service) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "onedrive": + page = {"value": [{"id": "synth_od_xyz", "name": "noprop.txt", "file": {}}]} + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = page + 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 adapter.list_folder(conn_id, user_id, parent_ref=None) + + assert isinstance(result, CloudListing) + if result.items: + for item in result.items: + # Absent metadata must be None, not 0, "", or other fabricated values + # size for a file with no getcontentlength should be None or 0 (not negative) + assert item.size is None or isinstance(item.size, int), ( + f"{provider}: size must be None or int, got {item.size!r}" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES) + async def test_provider_consumes_all_pages_before_complete(self, provider, factory_fn, stub_fn): + """complete=True only after all pages/responses are consumed.""" + from storage.cloud_base import CloudListing + adapter = factory_fn() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + if provider == "nextcloud": + # Single-response DAV listing — always complete if no error + with patch("storage.webdav_backend.validate_cloud_url"): + adapter._client.list = MagicMock(return_value=["file.txt"]) + adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100}) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + assert result.complete is True + elif provider == "webdav": + with patch("storage.webdav_backend.validate_cloud_url"): + adapter._client.list = MagicMock(return_value=["file.txt"]) + adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100}) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + assert result.complete is True + elif provider == "google_drive": + # Two pages — complete=True only after page2 consumed + fake_service = MagicMock() + fake_service.files().list().execute.side_effect = [ + { + "files": [{"id": "f1", "name": "a.pdf", "mimeType": "application/pdf", "size": "100"}], + "nextPageToken": "synth_page2", + }, + { + "files": [{"id": "f2", "name": "b.pdf", "mimeType": "application/pdf", "size": "200"}], + }, + ] + adapter._get_service = MagicMock(return_value=fake_service) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + assert result.complete is True + assert len(result.items) == 2 + elif provider == "onedrive": + page1 = { + "value": [{"id": "od1", "name": "a.txt", "file": {}, "size": 10}], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/drive/root/children?$skip=1", + } + page2 = { + "value": [{"id": "od2", "name": "b.txt", "file": {}, "size": 20}], + } + resp1 = MagicMock() + resp1.is_success = True + resp1.json.return_value = page1 + resp2 = MagicMock() + resp2.is_success = True + resp2.json.return_value = page2 + 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(side_effect=[resp1, resp2]) + mock_cls.return_value = mock_client + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + assert result.complete is True + assert len(result.items) == 2 + + @pytest.mark.asyncio + @pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES) + async def test_provider_page_failure_is_incomplete(self, provider, factory_fn, stub_fn): + """A failed fetch/parse returns complete=False — never a fabricated complete empty.""" + from storage.cloud_base import CloudListing + adapter = factory_fn() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + if provider in ("nextcloud", "webdav"): + with patch("storage.webdav_backend.validate_cloud_url"): + adapter._client.list = MagicMock(side_effect=Exception("network failure")) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "google_drive": + fake_service = MagicMock() + from googleapiclient.errors import HttpError + resp = MagicMock() + resp.status = 403 + fake_service.files().list().execute.side_effect = HttpError(resp, b"forbidden") + adapter._get_service = MagicMock(return_value=fake_service) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "onedrive": + err_resp = MagicMock() + err_resp.is_success = False + err_resp.status_code = 401 + 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=err_resp) + mock_cls.return_value = mock_client + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + + assert isinstance(result, CloudListing) + assert result.complete is False, ( + f"{provider}: fetch failure must return complete=False" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("provider,factory_fn,stub_fn", PROVIDER_CASES) + async def test_provider_listing_never_downloads_or_mutates( + self, provider, factory_fn, stub_fn + ): + """list_folder must not call get_object, put_object, delete_object, + presigned_get_url, or generate_presigned_put_url.""" + from storage.cloud_base import CloudListing + adapter = factory_fn() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + # Install failing spies on all forbidden methods + forbidden_patches = [] + for method in FORBIDDEN_METHODS: + if hasattr(adapter, method): + p = patch.object( + adapter, method, + side_effect=AssertionError(f"FORBIDDEN: {method} was called by list_folder"), + ) + forbidden_patches.append(p) + + for p in forbidden_patches: + p.start() + + try: + if provider in ("nextcloud", "webdav"): + with patch("storage.webdav_backend.validate_cloud_url"): + adapter._client.list = MagicMock(return_value=["file.txt"]) + adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100}) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "google_drive": + fake_service = MagicMock() + fake_service.files().list().execute.return_value = { + "files": [{"id": "synth_f1", "name": "x.pdf", "mimeType": "application/pdf", "size": "100"}] + } + # Also spy on get_media method of Drive service + fake_service.files().get_media.side_effect = AssertionError( + "FORBIDDEN: get_media called" + ) + adapter._get_service = MagicMock(return_value=fake_service) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + elif provider == "onedrive": + page = {"value": [{"id": "synth_od1", "name": "y.txt", "file": {}, "size": 10}]} + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = page + 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) + # Spy on PUT, POST, DELETE + mock_client.put = AsyncMock(side_effect=AssertionError("FORBIDDEN: put called")) + mock_client.post = AsyncMock(side_effect=AssertionError("FORBIDDEN: post called")) + mock_client.delete = AsyncMock(side_effect=AssertionError("FORBIDDEN: delete called")) + mock_cls.return_value = mock_client + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + + assert isinstance(result, CloudListing), ( + f"{provider}: result must be CloudListing after forbidden-method spy pass" + ) + finally: + for p in forbidden_patches: + p.stop() + + +class TestProviderParentRefPropagation: + """parent_ref is correctly propagated into each CloudResource.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize("provider,factory_fn,stub_fn", [ + pytest.param("webdav", _make_webdav_adapter, _stub_webdav, id="webdav"), + pytest.param("google_drive", _make_google_drive_adapter, _stub_google_drive, id="google_drive"), + pytest.param("onedrive", _make_onedrive_adapter, _stub_onedrive, id="onedrive"), + ]) + async def test_parent_ref_copied_to_resources(self, provider, factory_fn, stub_fn): + """Every resource.parent_ref equals the parent_ref argument.""" + adapter = factory_fn() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + parent = "synth_parent_folder_ref" + + if provider == "webdav": + with patch("storage.webdav_backend.validate_cloud_url"): + adapter._client.list = MagicMock(return_value=["file.txt"]) + adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100}) + result = await adapter.list_folder(conn_id, user_id, parent_ref=parent) + elif provider == "google_drive": + fake_service = MagicMock() + fake_service.files().list().execute.return_value = { + "files": [{"id": "synth_f2", "name": "z.pdf", "mimeType": "application/pdf", "size": "100"}] + } + adapter._get_service = MagicMock(return_value=fake_service) + result = await adapter.list_folder(conn_id, user_id, parent_ref=parent) + elif provider == "onedrive": + page = {"value": [{"id": "synth_od2", "name": "w.txt", "file": {}, "size": 20}]} + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = page + 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 adapter.list_folder(conn_id, user_id, parent_ref=parent) + + for item in result.items: + assert item.parent_ref == parent, ( + f"{provider}: resource.parent_ref={item.parent_ref!r} " + f"does not match argument parent_ref={parent!r}" + ) + + +class TestProviderHostileIdentityRejection: + """Provider response fields must not override trusted caller identity.""" + + @pytest.mark.asyncio + async def test_google_drive_hostile_ids_overridden(self): + """owner_id / connection_id in Drive response cannot replace trusted caller values.""" + from storage.google_drive_backend import GoogleDriveBackend + adapter = _make_google_drive_adapter() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + hostile_id = uuid.uuid4() + + # The Drive response does not carry connection_id or user_id, but even if it + # returned parent references that look like UUIDs, those must not replace them. + fake_service = MagicMock() + fake_service.files().list().execute.return_value = { + "files": [ + { + "id": str(hostile_id), # hostile provider_item_id != our user_id + "name": "file.pdf", + "mimeType": "application/pdf", + "size": "100", + "parents": [str(hostile_id)], # parent also hostile + } + ] + } + adapter._get_service = MagicMock(return_value=fake_service) + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + + for item in result.items: + assert item.connection_id == conn_id + assert item.user_id == user_id + # provider_item_id may equal hostile_id (it is opaque) — that is fine + # but connection_id and user_id must be the trusted caller values + + @pytest.mark.asyncio + async def test_onedrive_hostile_ids_overridden(self): + """owner from @odata response cannot replace trusted caller identity.""" + adapter = _make_onedrive_adapter() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + hostile_user = uuid.uuid4() + + page = { + "value": [ + { + "id": "od_item1", + "name": "x.txt", + "file": {}, + "size": 100, + "createdBy": {"user": {"id": str(hostile_user)}}, + } + ] + } + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = page + 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 adapter.list_folder(conn_id, user_id, parent_ref=None) + + for item in result.items: + assert item.connection_id == conn_id + assert item.user_id == user_id + + +class TestNextcloudSpecificContract: + """Nextcloud-specific regression tests (P0 from research).""" + + def test_nextcloud_signature_matches_canonical_webdav(self): + """ + inspect.signature(NextcloudBackend.list_folder) must match WebDAVBackend.list_folder. + + This test is the root-cause regression for the P0 defect documented in research: + NextcloudBackend.list_folder(folder_path="") breaks the canonical four-argument call. + Before Task 2 this test will fail; after Task 2 it must pass. + """ + from storage.nextcloud_backend import NextcloudBackend + from storage.webdav_backend import WebDAVBackend + + nc_sig = inspect.signature(NextcloudBackend.list_folder) + wd_sig = inspect.signature(WebDAVBackend.list_folder) + + nc_params = list(nc_sig.parameters.keys()) + wd_params = list(wd_sig.parameters.keys()) + + assert nc_params == wd_params, ( + f"NextcloudBackend.list_folder signature {nc_params!r} " + f"does not match WebDAVBackend.list_folder signature {wd_params!r}. " + f"Remove the incompatible override so Nextcloud inherits the canonical method." + ) + + @pytest.mark.asyncio + async def test_nextcloud_canonical_invocation_does_not_raise_type_error(self): + """ + Calling adapter.list_folder(connection_id, user_id) must not raise TypeError. + + This is the canonical test for the P0 production defect. The existing override + raises TypeError because the browse endpoint passes positional UUID arguments + but the override expects (folder_path: str = ""). + """ + adapter = _make_nextcloud_adapter() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + with patch("storage.webdav_backend.validate_cloud_url"): + adapter._client.list = MagicMock(return_value=["file.txt"]) + adapter._client.info = MagicMock(return_value={"isdir": False, "size": 100}) + # Must not raise TypeError — after Task 2 this invokes the inherited WebDAV method + result = await adapter.list_folder(conn_id, user_id) + + from storage.cloud_base import CloudListing + assert isinstance(result, CloudListing) + + def test_nextcloud_inherits_or_delegates_to_webdav_list_folder(self): + """ + NextcloudBackend.list_folder must be the same function as WebDAVBackend.list_folder + OR a method that wraps/delegates to it without changing the public contract. + """ + from storage.nextcloud_backend import NextcloudBackend + from storage.webdav_backend import WebDAVBackend + + # After Task 2: either NextcloudBackend does not define its own list_folder + # (direct inheritance), or it defines one with the matching signature. + nc_fn = NextcloudBackend.__dict__.get("list_folder") + if nc_fn is not None: + # If Nextcloud overrides list_folder, its signature must still be canonical + sig = inspect.signature(nc_fn) + params = list(sig.parameters.keys()) + # skip 'self' + expected = ["self", "connection_id", "user_id", "parent_ref", "page_token"] + assert params == expected, ( + f"NextcloudBackend defines its own list_folder but signature {params!r} " + f"is not canonical {expected!r}. Either remove the override or fix the signature." + ) + + +class TestGoogleDriveSpecificContract: + """Drive-specific normalization tests.""" + + @pytest.mark.asyncio + async def test_native_google_files_have_nullable_size(self): + """Native Google Workspace files (Docs, Sheets, etc.) must normalize with size=None.""" + adapter = _make_google_drive_adapter() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + fixture_data = _GD_FIXTURE["native_docs_nullable_size"] + fake_service = MagicMock() + fake_service.files().list().execute.return_value = fixture_data + adapter._get_service = MagicMock(return_value=fake_service) + + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + assert result.complete is True + for item in result.items: + assert item.kind == "file", f"Native Google type should be kind=file, got {item.kind!r}" + assert item.size is None, f"Native Google file size must be None, got {item.size!r}" + + @pytest.mark.asyncio + async def test_trashed_items_excluded(self): + """trashed=false in the query means trashed items never appear in results.""" + adapter = _make_google_drive_adapter() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + # The fixture confirms query uses trashed=false — the result set only has visible items + fixture_data = _GD_FIXTURE["trashed_excluded"] + fake_service = MagicMock() + fake_service.files().list().execute.return_value = fixture_data + adapter._get_service = MagicMock(return_value=fake_service) + + # Verify the query string passed to files().list() contains trashed=false + result = await adapter.list_folder(conn_id, user_id, parent_ref=None) + call_kwargs = fake_service.files().list.call_args + if call_kwargs: + q_param = call_kwargs[1].get("q", "") or (call_kwargs[0][0] if call_kwargs[0] else "") + assert "trashed=false" in q_param, ( + "Drive query must contain 'trashed=false' to exclude trash items" + ) + + +class TestOneDriveSpecificContract: + """OneDrive-specific normalization tests.""" + + @pytest.mark.asyncio + async def test_folder_identified_by_facet(self): + """OneDrive items with 'folder' key are kind='folder'.""" + adapter = _make_onedrive_adapter() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + page = _OD_FIXTURE["root_page1"].copy() + del page["@odata.nextLink"] + page["value"] = page["value"][:2] # first is folder, second is also folder + + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = page + 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 adapter.list_folder(conn_id, user_id, parent_ref=None) + + folders = [i for i in result.items if i.kind == "folder"] + assert len(folders) >= 1, "Expected at least one folder from OneDrive fixture" + + @pytest.mark.asyncio + async def test_nullable_metadata_items(self): + """OneDrive items with absent file.mimeType or size normalize to None.""" + adapter = _make_onedrive_adapter() + conn_id = uuid.uuid4() + user_id = uuid.uuid4() + + fixture_data = _OD_FIXTURE["nullable_metadata"] + mock_resp = MagicMock() + mock_resp.is_success = True + mock_resp.json.return_value = fixture_data + 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 adapter.list_folder(conn_id, user_id, parent_ref=None) + + assert len(result.items) == 1 + item = result.items[0] + 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)