Add sync engine, web UI, Docker setup, and tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
221
tests/test_phase_a_webdav.py
Normal file
221
tests/test_phase_a_webdav.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Phase A — WebDAV Container Tests
|
||||
|
||||
Integration tests against a running obsidian-webdav container.
|
||||
Skip these in CI unless the WebDAV service is available.
|
||||
|
||||
Run with:
|
||||
pytest tests/test_phase_a_webdav.py -m integration -v
|
||||
|
||||
Environment variables required:
|
||||
WEBDAV_URL e.g. http://100.x.x.x (Tailscale IP)
|
||||
WEBDAV_USER basic-auth username (default: obsidian)
|
||||
WEBDAV_PASS basic-auth password
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def webdav_base() -> str:
|
||||
url = os.environ.get("WEBDAV_URL", "").rstrip("/")
|
||||
if not url:
|
||||
pytest.skip("WEBDAV_URL not set — skipping WebDAV integration tests")
|
||||
return url
|
||||
|
||||
|
||||
def webdav_auth():
|
||||
return (
|
||||
os.environ.get("WEBDAV_USER", "obsidian"),
|
||||
os.environ.get("WEBDAV_PASS", ""),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dav():
|
||||
"""Requests session pre-configured for the WebDAV endpoint."""
|
||||
s = requests.Session()
|
||||
s.auth = webdav_auth()
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_filename():
|
||||
"""Unique filename so parallel test runs do not collide."""
|
||||
return f"test_{uuid.uuid4().hex[:8]}.md"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-A2 — Authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWebDAVAuth:
|
||||
|
||||
def test_unauthenticated_request_returns_401(self):
|
||||
"""GET without credentials must be rejected."""
|
||||
r = requests.get(webdav_base())
|
||||
assert r.status_code == 401, f"Expected 401, got {r.status_code}"
|
||||
|
||||
def test_wrong_password_returns_401(self):
|
||||
r = requests.get(webdav_base(), auth=("obsidian", "wrong-password"))
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_valid_credentials_succeed(self, dav):
|
||||
r = dav.request("PROPFIND", webdav_base(), headers={"Depth": "0"})
|
||||
assert r.status_code in (200, 207), f"Expected 200/207, got {r.status_code}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-A4 — WebDAV method support (PROPFIND, PUT, GET, DELETE)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWebDAVMethods:
|
||||
|
||||
def test_propfind_root_lists_contents(self, dav):
|
||||
"""PROPFIND depth=1 must enumerate the root of the vault."""
|
||||
r = dav.request(
|
||||
"PROPFIND",
|
||||
webdav_base(),
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
)
|
||||
assert r.status_code in (200, 207)
|
||||
assert len(r.content) > 0
|
||||
|
||||
def test_put_creates_file(self, dav, test_filename):
|
||||
url = f"{webdav_base()}/{test_filename}"
|
||||
content = b"# Test Note\nCreated by automated test.\n"
|
||||
r = dav.put(url, data=content)
|
||||
assert r.status_code in (200, 201, 204), f"PUT failed: {r.status_code}"
|
||||
|
||||
# Verify it exists
|
||||
r2 = dav.get(url)
|
||||
assert r2.status_code == 200
|
||||
assert b"# Test Note" in r2.content
|
||||
|
||||
# Cleanup
|
||||
dav.delete(url)
|
||||
|
||||
def test_put_updates_existing_file(self, dav, test_filename):
|
||||
url = f"{webdav_base()}/{test_filename}"
|
||||
dav.put(url, data=b"v1 content")
|
||||
|
||||
dav.put(url, data=b"v2 content updated")
|
||||
r = dav.get(url)
|
||||
assert b"v2 content" in r.content
|
||||
|
||||
dav.delete(url)
|
||||
|
||||
def test_delete_removes_file(self, dav, test_filename):
|
||||
url = f"{webdav_base()}/{test_filename}"
|
||||
dav.put(url, data=b"temporary file")
|
||||
|
||||
r = dav.delete(url)
|
||||
assert r.status_code in (200, 204)
|
||||
|
||||
r2 = dav.get(url)
|
||||
assert r2.status_code == 404
|
||||
|
||||
def test_get_nonexistent_returns_404(self, dav):
|
||||
r = dav.get(f"{webdav_base()}/does_not_exist_{uuid.uuid4().hex}.md")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_mkcol_creates_subdirectory(self, dav):
|
||||
dirname = f"test_dir_{uuid.uuid4().hex[:8]}"
|
||||
r = dav.request("MKCOL", f"{webdav_base()}/{dirname}")
|
||||
assert r.status_code in (200, 201, 207)
|
||||
|
||||
r2 = dav.request("PROPFIND", f"{webdav_base()}/{dirname}", headers={"Depth": "0"})
|
||||
assert r2.status_code in (200, 207)
|
||||
|
||||
dav.delete(f"{webdav_base()}/{dirname}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-A3 — .git/ directory protection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWebDAVGitProtection:
|
||||
|
||||
def test_git_directory_is_inaccessible(self, dav):
|
||||
"""The .git/ directory must not be served — nginx should deny it."""
|
||||
r = dav.request("PROPFIND", f"{webdav_base()}/.git/", headers={"Depth": "0"})
|
||||
assert r.status_code in (403, 404), (
|
||||
f"Expected 403/404 for /.git/ but got {r.status_code}. "
|
||||
"The WebDAV config must deny access to .git/"
|
||||
)
|
||||
|
||||
def test_git_config_file_is_inaccessible(self, dav):
|
||||
r = dav.get(f"{webdav_base()}/.git/config")
|
||||
assert r.status_code in (403, 404)
|
||||
|
||||
def test_git_head_file_is_inaccessible(self, dav):
|
||||
r = dav.get(f"{webdav_base()}/.git/HEAD")
|
||||
assert r.status_code in (403, 404)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-A1 — Bidirectional sync simulation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWebDAVFileOps:
|
||||
|
||||
def test_create_read_delete_roundtrip(self, dav, test_filename):
|
||||
"""Full create → read → delete cycle for a markdown file."""
|
||||
url = f"{webdav_base()}/{test_filename}"
|
||||
body = "---\noutline_id: test-001\n---\n# Test\nContent.\n"
|
||||
|
||||
# Create
|
||||
assert dav.put(url, data=body.encode()).status_code in (200, 201, 204)
|
||||
|
||||
# Read back — content must match
|
||||
r = dav.get(url)
|
||||
assert r.status_code == 200
|
||||
assert "outline_id: test-001" in r.text
|
||||
|
||||
# Delete
|
||||
assert dav.delete(url).status_code in (200, 204)
|
||||
|
||||
def test_unicode_content_preserved(self, dav, test_filename):
|
||||
url = f"{webdav_base()}/{test_filename}"
|
||||
body = "# Ünïcödé Héadïng\n\nGerman: Straße, Chinese: 你好\n".encode("utf-8")
|
||||
|
||||
dav.put(url, data=body)
|
||||
r = dav.get(url)
|
||||
assert "Straße" in r.text
|
||||
assert "你好" in r.text
|
||||
|
||||
dav.delete(url)
|
||||
|
||||
def test_large_file_survives_roundtrip(self, dav, test_filename):
|
||||
url = f"{webdav_base()}/{test_filename}"
|
||||
# 500 KB markdown file
|
||||
body = ("# Big Note\n" + "x" * 500_000).encode()
|
||||
|
||||
dav.put(url, data=body)
|
||||
r = dav.get(url)
|
||||
assert len(r.content) >= 500_000
|
||||
|
||||
dav.delete(url)
|
||||
|
||||
def test_obsidian_settings_directory_can_be_excluded(self, dav):
|
||||
"""
|
||||
Verify we can PUT to a path we'd want to ignore (.obsidian/)
|
||||
and then verify the nginx/webdav config (if configured) can block it.
|
||||
This test documents expected behaviour; if .obsidian/ IS accessible,
|
||||
it must be controlled at the Obsidian plugin level (ignore list).
|
||||
"""
|
||||
# This is an informational check, not a hard assertion —
|
||||
# .obsidian/ exclusion is handled by the remotely-save plugin config.
|
||||
r = dav.request("PROPFIND", f"{webdav_base()}/.obsidian/", headers={"Depth": "0"})
|
||||
# 404 = not present (preferred), 403 = blocked, 207 = accessible
|
||||
# All are valid — important thing is it is documented
|
||||
assert r.status_code in (403, 404, 207)
|
||||
Reference in New Issue
Block a user