WebDAV support!
This commit is contained in:
@@ -37,9 +37,6 @@ app.router.ALLOWED_METHODS = (
|
|||||||
"MOVE",
|
"MOVE",
|
||||||
"COPY",
|
"COPY",
|
||||||
"PROPFIND",
|
"PROPFIND",
|
||||||
"PROPPATCH",
|
|
||||||
"LOCK",
|
|
||||||
"UNLOCK",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
configure_main_logging()
|
configure_main_logging()
|
||||||
|
|||||||
+193
-3
@@ -3,11 +3,13 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from urllib.parse import unquote
|
from urllib.parse import quote as url_quote, unquote, urlparse
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
from sanic import Blueprint, empty, json
|
from sanic import Blueprint, HTTPResponse, empty, json
|
||||||
from sanic.exceptions import BadRequest, NotFound
|
from sanic.exceptions import BadRequest, NotFound
|
||||||
|
|
||||||
from cista import auth, config, watching
|
from cista import auth, config, watching
|
||||||
@@ -20,6 +22,13 @@ _CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")
|
|||||||
_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
||||||
_FILE_CHUNK_SIZE = 1 << 20
|
_FILE_CHUNK_SIZE = 1 << 20
|
||||||
|
|
||||||
|
_DAV_NS = "DAV:"
|
||||||
|
ET.register_namespace("D", _DAV_NS)
|
||||||
|
|
||||||
|
|
||||||
|
def _dav_tag(name: str) -> str:
|
||||||
|
return f"{{{_DAV_NS}}}{name}"
|
||||||
|
|
||||||
|
|
||||||
@bp.on_request
|
@bp.on_request
|
||||||
async def verify_fileserver(request):
|
async def verify_fileserver(request):
|
||||||
@@ -108,7 +117,8 @@ async def create_folder(request, name):
|
|||||||
return empty(status=201)
|
return empty(status=201)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<name:path>")
|
@bp.post("/", name="post_root", strict_slashes=False)
|
||||||
|
@bp.post("/<name:path>", name="post_path")
|
||||||
async def copy_or_move(request, name=""):
|
async def copy_or_move(request, name=""):
|
||||||
provided_args = set(request.args.keys())
|
provided_args = set(request.args.keys())
|
||||||
if not provided_args:
|
if not provided_args:
|
||||||
@@ -263,6 +273,99 @@ async def head_file(request, name=""):
|
|||||||
return await _send_static_file(request, name, head_only=True)
|
return await _send_static_file(request, name, head_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["OPTIONS"], name="options_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["OPTIONS"], name="options_path")
|
||||||
|
async def dav_options(request, name=""):
|
||||||
|
return HTTPResponse(
|
||||||
|
status=200,
|
||||||
|
headers={
|
||||||
|
"Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, POST",
|
||||||
|
"DAV": "1",
|
||||||
|
"MS-Author-Via": "DAV",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["PROPFIND"], name="propfind_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["PROPFIND"], name="propfind_path")
|
||||||
|
async def dav_propfind(request, name=""):
|
||||||
|
rel, path = _safe_relpath(name)
|
||||||
|
if not path.exists():
|
||||||
|
raise NotFound(f"Not found: {name}")
|
||||||
|
depth = request.headers.get("depth", "1").strip()
|
||||||
|
if depth == "infinity":
|
||||||
|
return HTTPResponse(status=403)
|
||||||
|
entries = await asyncio.to_thread(_collect_propfind_entries, rel, path, depth)
|
||||||
|
return HTTPResponse(
|
||||||
|
body=_build_propfind_xml(entries),
|
||||||
|
status=207,
|
||||||
|
content_type='application/xml; charset="utf-8"',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["COPY"], name="copy_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["COPY"], name="copy_path")
|
||||||
|
async def dav_copy(request, name=""):
|
||||||
|
dest_header = request.headers.get("destination")
|
||||||
|
if not dest_header:
|
||||||
|
raise BadRequest("Missing Destination header")
|
||||||
|
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
|
||||||
|
src_rel, src_abs = _safe_relpath(name)
|
||||||
|
dst_rel, dst_abs = _parse_webdav_destination(dest_header)
|
||||||
|
request.ctx._log_extra = f"→ {dst_rel}"
|
||||||
|
if not src_abs.exists():
|
||||||
|
raise NotFound(f"Source not found: {name}")
|
||||||
|
if src_abs == dst_abs:
|
||||||
|
raise BadRequest("Source and destination are the same")
|
||||||
|
dst_existed = dst_abs.exists()
|
||||||
|
if dst_existed and not overwrite:
|
||||||
|
return HTTPResponse(status=412)
|
||||||
|
if not dst_abs.parent.is_dir():
|
||||||
|
return HTTPResponse(status=409)
|
||||||
|
|
||||||
|
def _do_copy():
|
||||||
|
if dst_existed:
|
||||||
|
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
|
||||||
|
if src_abs.is_dir():
|
||||||
|
shutil.copytree(src_abs, dst_abs, ignore_dangling_symlinks=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src_abs, dst_abs)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_do_copy)
|
||||||
|
watching.notify_change(dst_rel, *dst_rel.parents)
|
||||||
|
return HTTPResponse(status=201 if not dst_existed else 204)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/", methods=["MOVE"], name="move_root", strict_slashes=False)
|
||||||
|
@bp.route("/<name:path>", methods=["MOVE"], name="move_path")
|
||||||
|
async def dav_move(request, name=""):
|
||||||
|
dest_header = request.headers.get("destination")
|
||||||
|
if not dest_header:
|
||||||
|
raise BadRequest("Missing Destination header")
|
||||||
|
overwrite = request.headers.get("overwrite", "T").strip().upper() != "F"
|
||||||
|
src_rel, src_abs = _safe_relpath(name)
|
||||||
|
dst_rel, dst_abs = _parse_webdav_destination(dest_header)
|
||||||
|
request.ctx._log_extra = f"→ {dst_rel}"
|
||||||
|
if not src_abs.exists():
|
||||||
|
raise NotFound(f"Source not found: {name}")
|
||||||
|
if src_abs == dst_abs:
|
||||||
|
return HTTPResponse(status=204)
|
||||||
|
dst_existed = dst_abs.exists()
|
||||||
|
if dst_existed and not overwrite:
|
||||||
|
return HTTPResponse(status=412)
|
||||||
|
if not dst_abs.parent.is_dir():
|
||||||
|
return HTTPResponse(status=409)
|
||||||
|
|
||||||
|
def _do_move():
|
||||||
|
if dst_existed:
|
||||||
|
shutil.rmtree(dst_abs) if dst_abs.is_dir() else dst_abs.unlink()
|
||||||
|
shutil.move(src_abs, dst_abs)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_do_move)
|
||||||
|
watching.notify_change(src_rel, *src_rel.parents, dst_rel, *dst_rel.parents)
|
||||||
|
return HTTPResponse(status=201 if not dst_existed else 204)
|
||||||
|
|
||||||
|
|
||||||
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
|
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
|
||||||
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
|
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
|
||||||
if m is None:
|
if m is None:
|
||||||
@@ -413,3 +516,90 @@ def _get_key_paths(wanted: set[str]) -> dict[str, PurePosixPath]:
|
|||||||
if len(ret) == len(wanted):
|
if len(ret) == len(wanted):
|
||||||
break
|
break
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WebDAV helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_webdav_destination(dest_header: str) -> tuple[PurePosixPath, Path]:
|
||||||
|
"""Parse a WebDAV Destination header and resolve it to a storage path."""
|
||||||
|
parsed = urlparse(dest_header)
|
||||||
|
raw_path = parsed.path # still percent-encoded
|
||||||
|
prefix = "/files"
|
||||||
|
if raw_path in (prefix, prefix + "/"):
|
||||||
|
rel_str = ""
|
||||||
|
elif raw_path.startswith(prefix + "/"):
|
||||||
|
rel_str = raw_path[len(prefix) + 1:]
|
||||||
|
else:
|
||||||
|
raise BadRequest("Destination must be within /files")
|
||||||
|
return _safe_relpath(rel_str)
|
||||||
|
|
||||||
|
|
||||||
|
def _rel_to_href(rel: PurePosixPath, is_dir: bool) -> str:
|
||||||
|
"""Build a DAV href from a storage-relative path."""
|
||||||
|
parts = rel.parts
|
||||||
|
if not parts:
|
||||||
|
return "/files/"
|
||||||
|
encoded = "/".join(url_quote(p, safe="") for p in parts)
|
||||||
|
href = f"/files/{encoded}"
|
||||||
|
return href + "/" if is_dir else href
|
||||||
|
|
||||||
|
|
||||||
|
def _dav_xml(element: ET.Element) -> bytes:
|
||||||
|
"""Serialise an ElementTree element to UTF-8 bytes with XML declaration."""
|
||||||
|
return (
|
||||||
|
b'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
+ ET.tostring(element, encoding="unicode").encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_propfind_entries(rel: PurePosixPath, path: Path, depth: str) -> list[dict]:
|
||||||
|
entries = [_propfind_entry(rel, path)]
|
||||||
|
if depth == "1" and path.is_dir():
|
||||||
|
for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name)):
|
||||||
|
child_rel = rel / child.name if rel.parts else PurePosixPath(child.name)
|
||||||
|
try:
|
||||||
|
entries.append(_propfind_entry(child_rel, child))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _propfind_entry(rel: PurePosixPath, path: Path) -> dict:
|
||||||
|
st = path.stat()
|
||||||
|
is_dir = path.is_dir()
|
||||||
|
return {
|
||||||
|
"href": _rel_to_href(rel, is_dir),
|
||||||
|
"name": rel.parts[-1] if rel.parts else "",
|
||||||
|
"is_dir": is_dir,
|
||||||
|
"size": st.st_size,
|
||||||
|
"etag": f'"{st.st_mtime:.0f}-{st.st_size}"',
|
||||||
|
"content_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
|
||||||
|
"last_modified": format_date_time(st.st_mtime),
|
||||||
|
"created": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_propfind_xml(entries: list[dict]) -> bytes:
|
||||||
|
multistatus = ET.Element(_dav_tag("multistatus"))
|
||||||
|
for e in entries:
|
||||||
|
response = ET.SubElement(multistatus, _dav_tag("response"))
|
||||||
|
ET.SubElement(response, _dav_tag("href")).text = e["href"]
|
||||||
|
propstat = ET.SubElement(response, _dav_tag("propstat"))
|
||||||
|
prop = ET.SubElement(propstat, _dav_tag("prop"))
|
||||||
|
rt = ET.SubElement(prop, _dav_tag("resourcetype"))
|
||||||
|
if e["is_dir"]:
|
||||||
|
ET.SubElement(rt, _dav_tag("collection"))
|
||||||
|
ET.SubElement(prop, _dav_tag("displayname")).text = e["name"]
|
||||||
|
ET.SubElement(prop, _dav_tag("getlastmodified")).text = e["last_modified"]
|
||||||
|
ET.SubElement(prop, _dav_tag("creationdate")).text = e["created"]
|
||||||
|
if not e["is_dir"]:
|
||||||
|
ET.SubElement(prop, _dav_tag("getcontentlength")).text = str(e["size"])
|
||||||
|
ET.SubElement(prop, _dav_tag("getcontenttype")).text = e["content_type"]
|
||||||
|
ET.SubElement(prop, _dav_tag("getetag")).text = e["etag"]
|
||||||
|
ET.SubElement(propstat, _dav_tag("status")).text = "HTTP/1.1 200 OK"
|
||||||
|
return _dav_xml(multistatus)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def setup_storage(tmp_path: Path):
|
|||||||
@pytest_asyncio.fixture()
|
@pytest_asyncio.fixture()
|
||||||
async def client(setup_storage: Path):
|
async def client(setup_storage: Path):
|
||||||
app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True)
|
app = Sanic(f"files-path-sec-test-{uuid4().hex}", strict_slashes=True)
|
||||||
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL")
|
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND")
|
||||||
app.blueprint(fileserver_bp)
|
app.blueprint(fileserver_bp)
|
||||||
yield app.asgi_client
|
yield app.asgi_client
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def setup_storage(tmp_path: Path):
|
|||||||
@pytest_asyncio.fixture()
|
@pytest_asyncio.fixture()
|
||||||
async def client(setup_storage: Path):
|
async def client(setup_storage: Path):
|
||||||
app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True)
|
app = Sanic(f"files-rest-test-{uuid4().hex}", strict_slashes=True)
|
||||||
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL")
|
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND")
|
||||||
app.blueprint(fileserver_bp)
|
app.blueprint(fileserver_bp)
|
||||||
yield app.asgi_client
|
yield app.asgi_client
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def setup_storage(tmp_path: Path):
|
|||||||
@pytest_asyncio.fixture()
|
@pytest_asyncio.fixture()
|
||||||
async def client(setup_storage: Path):
|
async def client(setup_storage: Path):
|
||||||
app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True)
|
app = Sanic(f"files-static-test-{uuid4().hex}", strict_slashes=True)
|
||||||
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL")
|
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND")
|
||||||
app.blueprint(fileserver_bp)
|
app.blueprint(fileserver_bp)
|
||||||
yield app.asgi_client
|
yield app.asgi_client
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""WebDAV protocol tests: OPTIONS, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK."""
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sanic import Sanic
|
||||||
|
|
||||||
|
from cista import config, watching
|
||||||
|
from cista.fileserver import bp as fileserver_bp
|
||||||
|
from cista.protocol import FileEntry
|
||||||
|
|
||||||
|
_DAV_NS = "DAV:"
|
||||||
|
_METHODS = ("MKCOL", "MOVE", "COPY", "PROPFIND")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def setup_storage(tmp_path: Path):
|
||||||
|
config.config = config.Config(path=tmp_path, listen=":0", public=True)
|
||||||
|
watching.state.root = []
|
||||||
|
watching.rootpath = tmp_path
|
||||||
|
yield tmp_path
|
||||||
|
watching.state.root = []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def client(setup_storage: Path):
|
||||||
|
app = Sanic(f"files-dav-test-{uuid4().hex}", strict_slashes=True)
|
||||||
|
app.router.ALLOWED_METHODS = (*app.router.ALLOWED_METHODS, *_METHODS)
|
||||||
|
app.blueprint(fileserver_bp)
|
||||||
|
yield app.asgi_client
|
||||||
|
|
||||||
|
|
||||||
|
def _dav(tag: str) -> str:
|
||||||
|
return f"{{{_DAV_NS}}}{tag}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_multistatus(body: bytes) -> list[ET.Element]:
|
||||||
|
root = ET.fromstring(body)
|
||||||
|
assert root.tag == _dav("multistatus")
|
||||||
|
return root.findall(_dav("response"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OPTIONS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_advertises_dav_class(client):
|
||||||
|
_, res = await client.options("/files/")
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "1" in res.headers.get("dav", "")
|
||||||
|
assert "PROPFIND" in res.headers.get("allow", "")
|
||||||
|
assert "COPY" in res.headers.get("allow", "")
|
||||||
|
assert "MOVE" in res.headers.get("allow", "")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_without_trailing_slash(client):
|
||||||
|
"""WebDAV clients (e.g. Windows) send OPTIONS /files without trailing slash."""
|
||||||
|
_, res = await client.options("/files")
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "1" in res.headers.get("dav", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PROPFIND
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_propfind_root_depth0(client, setup_storage: Path):
|
||||||
|
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "0"})
|
||||||
|
|
||||||
|
assert res.status_code == 207
|
||||||
|
responses = _parse_multistatus(res.body)
|
||||||
|
assert len(responses) == 1
|
||||||
|
href = responses[0].findtext(_dav("href"))
|
||||||
|
assert href == "/files/"
|
||||||
|
rt = responses[0].find(f".//{_dav('resourcetype')}/{_dav('collection')}")
|
||||||
|
assert rt is not None, "Root should be a collection"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_propfind_root_depth1_lists_children(client, setup_storage: Path):
|
||||||
|
(setup_storage / "alpha.txt").write_text("a", encoding="utf-8")
|
||||||
|
(setup_storage / "beta").mkdir()
|
||||||
|
|
||||||
|
_, res = await client.request("PROPFIND", "/files/", headers={"Depth": "1"})
|
||||||
|
|
||||||
|
assert res.status_code == 207
|
||||||
|
responses = _parse_multistatus(res.body)
|
||||||
|
hrefs = [r.findtext(_dav("href")) for r in responses]
|
||||||
|
assert "/files/" in hrefs
|
||||||
|
assert "/files/alpha.txt" in hrefs
|
||||||
|
assert "/files/beta/" in hrefs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_propfind_file_has_content_length(client, setup_storage: Path):
|
||||||
|
(setup_storage / "data.txt").write_text("hello", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request("PROPFIND", "/files/data.txt", headers={"Depth": "0"})
|
||||||
|
|
||||||
|
assert res.status_code == 207
|
||||||
|
responses = _parse_multistatus(res.body)
|
||||||
|
cl = responses[0].findtext(f".//{_dav('getcontentlength')}")
|
||||||
|
assert cl == "5"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_propfind_depth_infinity_rejected(client, setup_storage: Path):
|
||||||
|
_, res = await client.request(
|
||||||
|
"PROPFIND", "/files/", headers={"Depth": "infinity"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_propfind_missing_resource_returns_404(client):
|
||||||
|
_, res = await client.request("PROPFIND", "/files/no-such-file.txt")
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# COPY
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_file_to_new_path(client, setup_storage: Path):
|
||||||
|
(setup_storage / "src.txt").write_text("copy me", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request(
|
||||||
|
"COPY",
|
||||||
|
"/files/src.txt",
|
||||||
|
headers={"Destination": "http://localhost/files/dst.txt"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert (setup_storage / "src.txt").is_file()
|
||||||
|
assert (setup_storage / "dst.txt").read_text() == "copy me"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_overwrites_existing_by_default(client, setup_storage: Path):
|
||||||
|
(setup_storage / "src.txt").write_text("new", encoding="utf-8")
|
||||||
|
(setup_storage / "dst.txt").write_text("old", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request(
|
||||||
|
"COPY",
|
||||||
|
"/files/src.txt",
|
||||||
|
headers={"Destination": "http://localhost/files/dst.txt"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 204
|
||||||
|
assert (setup_storage / "dst.txt").read_text() == "new"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_overwrite_false_returns_412(client, setup_storage: Path):
|
||||||
|
(setup_storage / "src.txt").write_text("x", encoding="utf-8")
|
||||||
|
(setup_storage / "dst.txt").write_text("y", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request(
|
||||||
|
"COPY",
|
||||||
|
"/files/src.txt",
|
||||||
|
headers={
|
||||||
|
"Destination": "http://localhost/files/dst.txt",
|
||||||
|
"Overwrite": "F",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 412
|
||||||
|
assert (setup_storage / "dst.txt").read_text() == "y"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_directory_recursively(client, setup_storage: Path):
|
||||||
|
(setup_storage / "src").mkdir()
|
||||||
|
(setup_storage / "src" / "child.txt").write_text("child", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request(
|
||||||
|
"COPY",
|
||||||
|
"/files/src",
|
||||||
|
headers={"Destination": "http://localhost/files/dst"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert (setup_storage / "dst" / "child.txt").read_text() == "child"
|
||||||
|
assert (setup_storage / "src" / "child.txt").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_missing_parent_returns_409(client, setup_storage: Path):
|
||||||
|
(setup_storage / "src.txt").write_text("x", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request(
|
||||||
|
"COPY",
|
||||||
|
"/files/src.txt",
|
||||||
|
headers={"Destination": "http://localhost/files/nodir/dst.txt"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MOVE
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_move_renames_file(client, setup_storage: Path):
|
||||||
|
(setup_storage / "old.txt").write_text("data", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request(
|
||||||
|
"MOVE",
|
||||||
|
"/files/old.txt",
|
||||||
|
headers={"Destination": "http://localhost/files/new.txt"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert not (setup_storage / "old.txt").exists()
|
||||||
|
assert (setup_storage / "new.txt").read_text() == "data"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_move_overwrites_existing(client, setup_storage: Path):
|
||||||
|
(setup_storage / "src.txt").write_text("src", encoding="utf-8")
|
||||||
|
(setup_storage / "dst.txt").write_text("dst", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request(
|
||||||
|
"MOVE",
|
||||||
|
"/files/src.txt",
|
||||||
|
headers={"Destination": "http://localhost/files/dst.txt"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 204
|
||||||
|
assert not (setup_storage / "src.txt").exists()
|
||||||
|
assert (setup_storage / "dst.txt").read_text() == "src"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_move_overwrite_false_returns_412(client, setup_storage: Path):
|
||||||
|
(setup_storage / "src.txt").write_text("src", encoding="utf-8")
|
||||||
|
(setup_storage / "dst.txt").write_text("dst", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request(
|
||||||
|
"MOVE",
|
||||||
|
"/files/src.txt",
|
||||||
|
headers={
|
||||||
|
"Destination": "http://localhost/files/dst.txt",
|
||||||
|
"Overwrite": "F",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 412
|
||||||
|
assert (setup_storage / "src.txt").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_move_same_source_and_dest_is_noop(client, setup_storage: Path):
|
||||||
|
(setup_storage / "file.txt").write_text("x", encoding="utf-8")
|
||||||
|
|
||||||
|
_, res = await client.request(
|
||||||
|
"MOVE",
|
||||||
|
"/files/file.txt",
|
||||||
|
headers={"Destination": "http://localhost/files/file.txt"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 204
|
||||||
|
assert (setup_storage / "file.txt").is_file()
|
||||||
Reference in New Issue
Block a user