diff --git a/backend/tests/test_webdav_backend.py b/backend/tests/test_webdav_backend.py new file mode 100644 index 0000000..8ba23e4 --- /dev/null +++ b/backend/tests/test_webdav_backend.py @@ -0,0 +1,188 @@ +""" +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")