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
This commit is contained in:
curo1305
2026-06-22 18:01:15 +02:00
parent efb596433c
commit fd6b561899
2 changed files with 978 additions and 0 deletions
@@ -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)"
)