WebDAV support!
This commit is contained in:
@@ -37,9 +37,6 @@ app.router.ALLOWED_METHODS = (
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"PROPFIND",
|
||||
"PROPPATCH",
|
||||
"LOCK",
|
||||
"UNLOCK",
|
||||
)
|
||||
|
||||
configure_main_logging()
|
||||
|
||||
+193
-3
@@ -3,11 +3,13 @@ import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
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 sanic import Blueprint, empty, json
|
||||
from sanic import Blueprint, HTTPResponse, empty, json
|
||||
from sanic.exceptions import BadRequest, NotFound
|
||||
|
||||
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*)$")
|
||||
_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
|
||||
async def verify_fileserver(request):
|
||||
@@ -108,7 +117,8 @@ async def create_folder(request, name):
|
||||
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=""):
|
||||
provided_args = set(request.args.keys())
|
||||
if not provided_args:
|
||||
@@ -263,6 +273,99 @@ async def head_file(request, name=""):
|
||||
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]:
|
||||
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
|
||||
if m is None:
|
||||
@@ -413,3 +516,90 @@ def _get_key_paths(wanted: set[str]) -> dict[str, PurePosixPath]:
|
||||
if len(ret) == len(wanted):
|
||||
break
|
||||
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()
|
||||
async def client(setup_storage: Path):
|
||||
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)
|
||||
yield app.asgi_client
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def setup_storage(tmp_path: Path):
|
||||
@pytest_asyncio.fixture()
|
||||
async def client(setup_storage: Path):
|
||||
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)
|
||||
yield app.asgi_client
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ def setup_storage(tmp_path: Path):
|
||||
@pytest_asyncio.fixture()
|
||||
async def client(setup_storage: Path):
|
||||
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)
|
||||
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