Files
kite/backend/tests/test_cloud_provider_contract.py
curo1305 fd6b561899 test(13-01): extend provider contract suites for four-provider mutable-operation parity
- Add Phase 13 mutable-operation RED tests to test_cloud_backends.py:
  TestGoogleDriveMutableContract (D-17 scope, create/rename/move/delete/upload),
  TestOneDriveMutableContract (CONN-02 token handoff, permanent-delete disclosure,
  nextLink SSRF guard), TestNextcloudMutableContract (create-folder SSRF, delete
  normalization), TestWebDAVMutableContract (permanent-delete, move SSRF, no
  cloud_items imports)
- Add Phase 13 mutable-operation RED tests to test_cloud_provider_contract.py:
  TestMutableAdapterContract (method existence, async contract, canonical signatures
  for all four providers), TestMutableAdapterResultNormalization (normalized kind/reason
  return types, conflict normalization documentation, unsupported-capability disclosure)
- All new tests fail against the current codebase — mutable adapter methods do not
  exist yet (expected RED); all prior Phase 12 tests remain green
2026-06-22 18:01:15 +02:00

1122 lines
51 KiB
Python

"""
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)
# =============================================================================
# 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)"
)