- Remove incompatible NextcloudBackend.list_folder(folder_path="") override so Nextcloud inherits canonical WebDAVBackend.list_folder(connection_id, user_id, parent_ref=None, page_token=None) -> CloudListing - Add normalize_nextcloud_url() to cloud_utils.py: idempotent canonical DAV root derivation, username percent-encoding, https-only, rejects userinfo/ query/fragment, validates via SSRF guard before return - Use normalize_nextcloud_url in cloud_backend_factory for nextcloud provider - Add TestNextcloudBackendNoListFolderOverride and TestNextcloudUrlNormalization to test_webdav_backend.py - All 9 Nextcloud contract failures now pass; full focused suite: 158 passed
292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""
|
|
Tests for WebDAVBackend and NextcloudBackend (Plan 05-04).
|
|
|
|
TDD RED phase — all tests fail until backend/storage/webdav_backend.py and
|
|
backend/storage/nextcloud_backend.py are implemented.
|
|
|
|
Covers:
|
|
- WebDAVBackend subclasses StorageBackend (all 7 abstract methods present)
|
|
- All 7 methods are async coroutines
|
|
- SSRF guard: construction with private/localhost URL raises ValueError
|
|
- presigned_get_url and generate_presigned_put_url raise NotImplementedError
|
|
- _make_path constructs correct WebDAV path
|
|
- NextcloudBackend subclasses WebDAVBackend
|
|
- NextcloudBackend inherits SSRF guard
|
|
- NextcloudBackend has list_folder (async)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
class TestWebDAVBackendStructure:
|
|
"""Static structure tests — no network calls required."""
|
|
|
|
def test_webdav_backend_importable(self):
|
|
from storage.webdav_backend import WebDAVBackend # noqa: F401
|
|
|
|
def test_webdav_backend_is_storage_backend_subclass(self):
|
|
from storage.base import StorageBackend
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
assert issubclass(WebDAVBackend, StorageBackend)
|
|
|
|
@pytest.mark.parametrize(
|
|
"method",
|
|
[
|
|
"put_object",
|
|
"get_object",
|
|
"delete_object",
|
|
"presigned_get_url",
|
|
"health_check",
|
|
"generate_presigned_put_url",
|
|
"stat_object",
|
|
],
|
|
)
|
|
def test_all_7_methods_are_async(self, method):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
fn = getattr(WebDAVBackend, method)
|
|
assert inspect.iscoroutinefunction(fn), f"{method} is not async"
|
|
|
|
|
|
class TestWebDAVBackendSSRF:
|
|
"""SSRF guard tests — construction with blocked URL must raise ValueError."""
|
|
|
|
def test_localhost_raises(self):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
with pytest.raises(ValueError):
|
|
WebDAVBackend("http://localhost/dav", "user", "pass")
|
|
|
|
def test_127_x_raises(self):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
with pytest.raises(ValueError):
|
|
WebDAVBackend("http://127.0.0.1/dav", "user", "pass")
|
|
|
|
def test_10_x_raises(self):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
with pytest.raises(ValueError):
|
|
WebDAVBackend("http://10.0.0.1/dav", "user", "pass")
|
|
|
|
def test_192_168_raises(self):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
with pytest.raises(ValueError):
|
|
WebDAVBackend("http://192.168.1.1/dav", "user", "pass")
|
|
|
|
def test_169_254_raises(self):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
with pytest.raises(ValueError):
|
|
WebDAVBackend("http://169.254.169.254/dav", "user", "pass")
|
|
|
|
|
|
class TestWebDAVBackendNotImplemented:
|
|
"""presigned methods must raise NotImplementedError (D-14)."""
|
|
|
|
async def test_presigned_get_url_raises(self):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
backend = WebDAVBackend.__new__(WebDAVBackend)
|
|
backend._server_url = "https://8.8.8.8/dav"
|
|
with pytest.raises(NotImplementedError):
|
|
await backend.presigned_get_url("some/key")
|
|
|
|
async def test_generate_presigned_put_url_raises(self):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
backend = WebDAVBackend.__new__(WebDAVBackend)
|
|
backend._server_url = "https://8.8.8.8/dav"
|
|
with pytest.raises(NotImplementedError):
|
|
await backend.generate_presigned_put_url("some/key")
|
|
|
|
|
|
class TestWebDAVMakePath:
|
|
"""_make_path must produce percent-encoded WebDAV paths (Pitfall 2)."""
|
|
|
|
def test_make_path_basic(self):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
backend = WebDAVBackend.__new__(WebDAVBackend)
|
|
path = backend._make_path("user-uuid", "doc-uuid", ".pdf")
|
|
assert path == "docuvault/user-uuid/doc-uuid.pdf"
|
|
|
|
def test_make_path_encodes_special_chars(self):
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
backend = WebDAVBackend.__new__(WebDAVBackend)
|
|
# UUIDs are alphanumeric and hyphens — no encoding needed for typical values
|
|
# This test ensures the encoding is applied (result should not contain raw spaces)
|
|
path = backend._make_path("user id", "doc id", ".pdf")
|
|
assert " " not in path
|
|
assert "user%20id" in path or "user+id" in path or "user%2520id" in path
|
|
|
|
|
|
class TestNextcloudBackendStructure:
|
|
"""NextcloudBackend structure tests."""
|
|
|
|
def test_nextcloud_importable(self):
|
|
from storage.nextcloud_backend import NextcloudBackend # noqa: F401
|
|
|
|
def test_nextcloud_is_webdav_subclass(self):
|
|
from storage.nextcloud_backend import NextcloudBackend
|
|
from storage.webdav_backend import WebDAVBackend
|
|
|
|
assert issubclass(NextcloudBackend, WebDAVBackend)
|
|
|
|
def test_nextcloud_is_storage_backend_subclass(self):
|
|
from storage.base import StorageBackend
|
|
from storage.nextcloud_backend import NextcloudBackend
|
|
|
|
assert issubclass(NextcloudBackend, StorageBackend)
|
|
|
|
@pytest.mark.parametrize(
|
|
"method",
|
|
[
|
|
"put_object",
|
|
"get_object",
|
|
"delete_object",
|
|
"presigned_get_url",
|
|
"health_check",
|
|
"generate_presigned_put_url",
|
|
"stat_object",
|
|
],
|
|
)
|
|
def test_all_7_methods_async_on_nextcloud(self, method):
|
|
from storage.nextcloud_backend import NextcloudBackend
|
|
|
|
fn = getattr(NextcloudBackend, method)
|
|
assert inspect.iscoroutinefunction(fn), f"{method} is not async"
|
|
|
|
def test_list_folder_present_and_async(self):
|
|
from storage.nextcloud_backend import NextcloudBackend
|
|
|
|
assert hasattr(NextcloudBackend, "list_folder")
|
|
assert inspect.iscoroutinefunction(NextcloudBackend.list_folder)
|
|
|
|
|
|
class TestNextcloudBackendSSRF:
|
|
"""SSRF guard inherited from WebDAVBackend.__init__."""
|
|
|
|
def test_10_x_raises_inherited(self):
|
|
from storage.nextcloud_backend import NextcloudBackend
|
|
|
|
with pytest.raises(ValueError):
|
|
NextcloudBackend("http://10.0.0.1/dav", "user", "pass")
|
|
|
|
def test_localhost_raises_inherited(self):
|
|
from storage.nextcloud_backend import NextcloudBackend
|
|
|
|
with pytest.raises(ValueError):
|
|
NextcloudBackend("http://localhost/dav", "user", "pass")
|
|
|
|
|
|
class TestNextcloudBackendNoListFolderOverride:
|
|
"""Phase 12.1 P0 fix: NextcloudBackend must NOT override list_folder."""
|
|
|
|
def test_nextcloud_does_not_define_own_list_folder(self):
|
|
"""list_folder must not appear in NextcloudBackend.__dict__ (no override)."""
|
|
from storage.nextcloud_backend import NextcloudBackend
|
|
assert "list_folder" not in NextcloudBackend.__dict__, (
|
|
"NextcloudBackend must not define its own list_folder override. "
|
|
"Remove the incompatible legacy method so Nextcloud inherits "
|
|
"the canonical WebDAVBackend.list_folder implementation."
|
|
)
|
|
|
|
def test_nextcloud_list_folder_is_same_as_webdav(self):
|
|
"""NextcloudBackend.list_folder must be the exact WebDAVBackend method."""
|
|
from storage.nextcloud_backend import NextcloudBackend
|
|
from storage.webdav_backend import WebDAVBackend
|
|
assert NextcloudBackend.list_folder is WebDAVBackend.list_folder, (
|
|
"NextcloudBackend.list_folder must be inherited from WebDAVBackend "
|
|
"— not a re-defined method."
|
|
)
|
|
|
|
def test_nextcloud_signature_matches_webdav(self):
|
|
"""Inspect signature — NextcloudBackend.list_folder must accept canonical args."""
|
|
import inspect
|
|
from storage.nextcloud_backend import NextcloudBackend
|
|
sig = inspect.signature(NextcloudBackend.list_folder)
|
|
params = list(sig.parameters.keys())
|
|
assert "connection_id" in params
|
|
assert "user_id" in params
|
|
assert "parent_ref" in params
|
|
assert "page_token" in params
|
|
|
|
|
|
class TestNextcloudUrlNormalization:
|
|
"""Tests for normalize_nextcloud_url helper in cloud_utils."""
|
|
|
|
def test_bare_origin_normalized(self):
|
|
"""Bare https origin normalized to DAV root path."""
|
|
from unittest.mock import patch
|
|
from storage.cloud_utils import normalize_nextcloud_url
|
|
with patch("storage.cloud_utils.validate_cloud_url"):
|
|
result = normalize_nextcloud_url("https://nc.example.com", "alice")
|
|
assert result == "https://nc.example.com/remote.php/dav/files/alice/"
|
|
|
|
def test_origin_with_trailing_slash(self):
|
|
"""Origin with trailing slash is idempotent in result."""
|
|
from unittest.mock import patch
|
|
from storage.cloud_utils import normalize_nextcloud_url
|
|
with patch("storage.cloud_utils.validate_cloud_url"):
|
|
result = normalize_nextcloud_url("https://nc.example.com/", "alice")
|
|
assert result == "https://nc.example.com/remote.php/dav/files/alice/"
|
|
|
|
def test_subpath_preserved(self):
|
|
"""Deployment subpath is preserved in canonical URL."""
|
|
from unittest.mock import patch
|
|
from storage.cloud_utils import normalize_nextcloud_url
|
|
with patch("storage.cloud_utils.validate_cloud_url"):
|
|
result = normalize_nextcloud_url("https://example.com/nextcloud", "bob")
|
|
assert result == "https://example.com/nextcloud/remote.php/dav/files/bob/"
|
|
|
|
def test_already_canonical_is_idempotent(self):
|
|
"""Already canonical URL is returned unchanged."""
|
|
from unittest.mock import patch
|
|
from storage.cloud_utils import normalize_nextcloud_url
|
|
canonical = "https://nc.example.com/remote.php/dav/files/alice/"
|
|
with patch("storage.cloud_utils.validate_cloud_url"):
|
|
result = normalize_nextcloud_url(canonical, "alice")
|
|
assert result == canonical
|
|
|
|
def test_username_percent_encoded(self):
|
|
"""Username with special characters is percent-encoded."""
|
|
from unittest.mock import patch
|
|
from storage.cloud_utils import normalize_nextcloud_url
|
|
with patch("storage.cloud_utils.validate_cloud_url"):
|
|
result = normalize_nextcloud_url("https://nc.example.com", "first last")
|
|
assert "first%20last" in result
|
|
assert " " not in result
|
|
|
|
def test_rejects_http_scheme(self):
|
|
"""http:// URL must raise ValueError (only https allowed for Nextcloud)."""
|
|
from storage.cloud_utils import normalize_nextcloud_url
|
|
with pytest.raises(ValueError, match="https"):
|
|
normalize_nextcloud_url("http://nc.example.com", "alice")
|
|
|
|
def test_rejects_userinfo(self):
|
|
"""URL with user:pass@ in it must raise ValueError."""
|
|
from storage.cloud_utils import normalize_nextcloud_url
|
|
with pytest.raises(ValueError, match="userinfo"):
|
|
normalize_nextcloud_url("https://user:pass@nc.example.com", "alice")
|
|
|
|
def test_rejects_query_string(self):
|
|
"""URL with ?query= must raise ValueError."""
|
|
from storage.cloud_utils import normalize_nextcloud_url
|
|
with pytest.raises(ValueError, match="query"):
|
|
normalize_nextcloud_url("https://nc.example.com?foo=bar", "alice")
|
|
|
|
def test_rejects_fragment(self):
|
|
"""URL with #fragment must raise ValueError."""
|
|
from storage.cloud_utils import normalize_nextcloud_url
|
|
with pytest.raises(ValueError, match="fragment"):
|
|
normalize_nextcloud_url("https://nc.example.com#section", "alice")
|