#!/usr/bin/env python3 """Exercise ImageFind's WebDAV server through its public direct listener.""" from __future__ import annotations import argparse import hashlib import json import time from pathlib import Path from typing import Any from urllib.parse import quote import httpx def call(client: httpx.Client, method: str, path: str, **kwargs: Any) -> httpx.Response: response = client.request(method, path, **kwargs) if response.is_error: raise RuntimeError(f"{method} {path} -> {response.status_code}: {response.text[:1000]}") return response def digest(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def encoded(parts: list[str]) -> str: return "/webdav/" + "/".join(quote(part, safe="") for part in parts) def videos(client: httpx.Client) -> list[dict[str, Any]]: return call(client, "GET", "/api/v1/videos?limit=500").json() def visible_video(client: httpx.Client, filename: str) -> dict[str, Any] | None: return next((item for item in videos(client) if item.get("source_key", "").endswith("/" + filename)), None) def wait_upload(client: httpx.Client, upload_id: str, timeout: int = 240) -> dict[str, Any]: deadline = time.monotonic() + timeout while time.monotonic() < deadline: rows = call(client, "GET", "/api/v1/uploads?limit=500").json() upload = next((item for item in rows if item["id"] == upload_id), None) if upload and upload["status"] in {"completed", "failed", "cancelled"}: if upload["status"] != "completed": raise AssertionError(f"WebDAV upload failed: {upload.get('message') or upload.get('error')}") return upload time.sleep(1) raise TimeoutError(f"WebDAV upload did not complete: {upload_id}") def wait_video(client: httpx.Client, filename: str, timeout: int = 90) -> dict[str, Any]: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if video := visible_video(client, filename): return video time.sleep(1) raise TimeoutError(f"WebDAV video did not appear: {filename}") def upload_id(response: httpx.Response) -> str: value = response.headers.get("x-imagefind-upload-id") if not value: raise AssertionError(f"WebDAV response omitted upload id: {dict(response.headers)}") return value def collection_video_path(nodes: list[dict[str, Any]], video_id: str, parents: list[str] | None = None): parents = list(parents or []) for node in nodes: if node.get("kind") == "group": found = collection_video_path(node.get("children") or [], video_id, [*parents, node["name"]]) if found is not None: return found elif node.get("video_id") == video_id: return parents return None def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--base-url", required=True) parser.add_argument("--token-file", type=Path, required=True) parser.add_argument("--run-dir", type=Path, required=True) parser.add_argument("--case-id", default="v045") args = parser.parse_args() state = json.loads((args.run_dir / "state.json").read_text()) token = args.token_file.read_text().strip() prefix = f"{state['run_id']}-{args.case_id.strip()}-dav" payload = (args.run_dir / "e2e-positive.mp4").read_bytes() checksum = digest(payload) api = httpx.Client( base_url=args.base_url.rstrip("/"), headers={"Authorization": f"Bearer {token}"}, timeout=httpx.Timeout(60, connect=10), ) dav = httpx.Client( base_url=args.base_url.rstrip("/"), auth=httpx.BasicAuth("imagefind", token), timeout=httpx.Timeout(120, connect=10, write=120, read=120), ) config = call(api, "GET", "/api/v1/webdav/config").json() if not config.get("enabled") or config.get("source_id") != state["source_id"]: raise AssertionError(f"unexpected WebDAV config: {config}") options = call(dav, "OPTIONS", "/webdav/") if "PROPFIND" not in options.headers.get("allow", ""): raise AssertionError("WebDAV OPTIONS omitted PROPFIND") root = call(dav, "PROPFIND", "/webdav/", headers={"Depth": "1"}) if "multistatus" not in root.text: raise AssertionError("WebDAV root did not return a multistatus document") root_name = f"{prefix}-root.mp4" root_response = call( dav, "PUT", encoded([root_name]), content=payload, headers={"X-Content-SHA256": checksum}, ) root_task = wait_upload(api, upload_id(root_response)) root_video = wait_video(api, root_name) collection_name = f"{prefix}-collection" groups = [f"level-{number:02d}" for number in range(1, 6)] path: list[str] = [collection_name] for part in [collection_name, *groups]: if part != collection_name: path.append(part) response = dav.request("MKCOL", encoded(path)) if response.status_code not in {201, 405}: raise RuntimeError(f"MKCOL {path} -> {response.status_code}: {response.text[:1000]}") resume_name = f"{prefix}-resume.mp4" resume_path = encoded([collection_name, *groups, resume_name]) midpoint = len(payload) // 2 first = call( dav, "PUT", resume_path, content=payload[:midpoint], headers={ "Content-Range": f"bytes 0-{midpoint - 1}/{len(payload)}", "X-Content-SHA256": checksum, }, ) if first.status_code != 204 or int(first.headers.get("upload-offset", 0)) != midpoint: raise AssertionError(f"unexpected partial PUT response: {first.status_code} {dict(first.headers)}") if visible_video(api, resume_name): raise AssertionError("partial WebDAV upload leaked into /videos") staged = call(dav, "HEAD", resume_path) if int(staged.headers.get("upload-offset", 0)) != midpoint: raise AssertionError(f"HEAD did not expose resume offset: {dict(staged.headers)}") second = call( dav, "PUT", resume_path, content=payload[midpoint:], headers={ "Content-Range": f"bytes {midpoint}-{len(payload) - 1}/{len(payload)}", "X-Content-SHA256": checksum, }, ) resume_task = wait_upload(api, upload_id(second)) resume_video = wait_video(api, resume_name) part_name = f"{prefix}-move.mp4.part" final_name = f"{prefix}-move.mp4" part_path = encoded([collection_name, *groups, part_name]) final_path = encoded([collection_name, *groups, final_name]) temporary = call( dav, "PUT", part_path, content=payload, headers={"X-Content-SHA256": checksum}, ) if temporary.headers.get("x-imagefind-upload-id"): raise AssertionError("temporary WebDAV name was committed before MOVE") moved = call( dav, "MOVE", part_path, headers={"Destination": args.base_url.rstrip("/") + final_path, "Overwrite": "F"}, ) move_task = wait_upload(api, upload_id(moved)) move_video = wait_video(api, final_name) started = time.perf_counter() duplicate = call( dav, "PUT", resume_path, content=payload, headers={"X-Content-SHA256": checksum, "Expect": "100-continue"}, ) dedupe_ms = round((time.perf_counter() - started) * 1000, 1) if duplicate.status_code != 204 or duplicate.headers.get("x-imagefind-deduplicated") != "true": raise AssertionError(f"same-path duplicate was not acknowledged: {dict(duplicate.headers)}") if duplicate.headers.get("x-imagefind-upload-id") != resume_task["id"]: raise AssertionError("deduplicated PUT did not reference the original upload task") ranged = call(dav, "GET", final_path, headers={"Range": "bytes=0-63"}) if ranged.status_code != 206 or ranged.content != payload[:64]: raise AssertionError("WebDAV Range read returned unexpected bytes") collections = call(api, "GET", "/api/v1/collections").json() collection = next((item for item in collections if item["name"] == collection_name), None) if not collection: raise AssertionError("WebDAV directory did not create a collection") detail = call(api, "GET", f"/api/v1/collections/{collection['id']}").json() for video in (resume_video, move_video): actual_path = collection_video_path(detail.get("items") or [], video["id"]) if actual_path != groups: raise AssertionError(f"collection hierarchy mismatch: {video['id']} -> {actual_path}") if any(item["id"] == root_video["id"] for item in detail.get("videos") or []): raise AssertionError("root WebDAV upload unexpectedly joined a collection") report = { "root_upload": {"id": root_task["id"], "video_id": root_video["id"]}, "resumable_upload": { "id": resume_task["id"], "video_id": resume_video["id"], "first_offset": midpoint, "final_size": len(payload), }, "temporary_move": {"id": move_task["id"], "video_id": move_video["id"]}, "deduplicated": True, "dedupe_latency_ms": dedupe_ms, "range_read": len(ranged.content), "collection_id": collection["id"], "collection_depth": len(groups), } (args.run_dir / "webdav-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") print(json.dumps(report, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()