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