mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
6caf86ba96
Adds a "Download Folder" item to the workspace file-tree right-click menu and a GET /api/folder/download endpoint that streams the directory as a zip with Content-Disposition: attachment. Configurable caps: HERMES_WEBUI_FOLDER_ZIP_MAX_MB (default 1024) HERMES_WEBUI_FOLDER_ZIP_MAX_FILES (default 50000) Pre-flights the walk so cap-exceeded returns 413 + JSON BEFORE any zip bytes are sent. Symlinks resolving outside the workspace are skipped. Mirrors the existing _handle_file_raw shape (session_id resolution, safe_resolve, RFC 5987 filename via _content_disposition_value). Stdlib zipfile only; no new dependencies. Tests: 11 static-inspection tests matching the style of tests/test_issue1867_upload_size_preflight.py. All passing on Python 3.11/3.12/3.13.
103 lines
3.8 KiB
Python
103 lines
3.8 KiB
Python
"""Tests for /api/folder/download — matches the static-inspection style used
|
|
elsewhere in the hermes-webui test suite (see tests/test_issue1867_upload_size_preflight.py).
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ROUTES_PY = ROOT / "api" / "routes.py"
|
|
UI_JS = ROOT / "static" / "ui.js"
|
|
|
|
|
|
def test_folder_download_handler_defined():
|
|
src = ROUTES_PY.read_text(encoding="utf-8")
|
|
assert "def _handle_folder_download(handler, parsed):" in src
|
|
assert "/api/folder/download?session_id=" in src # in handler docstring
|
|
assert 'Content-Type", "application/zip"' in src
|
|
assert "zipfile.ZipFile(handler.wfile" in src
|
|
|
|
|
|
def test_folder_download_dispatch_registered():
|
|
src = ROUTES_PY.read_text(encoding="utf-8")
|
|
assert 'parsed.path == "/api/folder/download"' in src
|
|
assert "_handle_folder_download(handler, parsed)" in src
|
|
|
|
|
|
def test_folder_download_uses_safe_resolve():
|
|
src = ROUTES_PY.read_text(encoding="utf-8")
|
|
handler_idx = src.index("def _handle_folder_download")
|
|
end_idx = src.index("\n\ndef ", handler_idx + 1)
|
|
body = src[handler_idx:end_idx]
|
|
assert "safe_resolve(Path(s.workspace), rel)" in body
|
|
assert "ValueError" in body
|
|
|
|
|
|
def test_folder_download_skips_escaping_symlinks():
|
|
src = ROUTES_PY.read_text(encoding="utf-8")
|
|
collect_idx = src.index("def _folder_download_collect")
|
|
end_idx = src.index("\n\ndef ", collect_idx + 1)
|
|
body = src[collect_idx:end_idx]
|
|
assert "followlinks=False" in body
|
|
assert "is_symlink()" in body
|
|
assert "is_relative_to(workspace_root)" in body
|
|
|
|
|
|
def test_folder_download_respects_max_files_env():
|
|
src = ROUTES_PY.read_text(encoding="utf-8")
|
|
assert 'HERMES_WEBUI_FOLDER_ZIP_MAX_FILES' in src
|
|
assert '"too many files"' in src
|
|
assert 'status=413' in src
|
|
|
|
|
|
def test_folder_download_respects_max_bytes_env():
|
|
src = ROUTES_PY.read_text(encoding="utf-8")
|
|
assert 'HERMES_WEBUI_FOLDER_ZIP_MAX_MB' in src
|
|
assert '"folder too large"' in src
|
|
assert 'limit_bytes' in src
|
|
|
|
|
|
def test_folder_download_preflights_before_streaming():
|
|
"""Pre-flight collect must run BEFORE send_response so 413 can return JSON."""
|
|
src = ROUTES_PY.read_text(encoding="utf-8")
|
|
handler_idx = src.index("def _handle_folder_download")
|
|
end_idx = src.index("\n\n# ", handler_idx) if "\n\n# " in src[handler_idx:] else len(src)
|
|
body = src[handler_idx:end_idx]
|
|
collect_call = body.index("_folder_download_collect")
|
|
send_response = body.index("handler.send_response(200)")
|
|
limit_check = body.index('"too many files"')
|
|
assert collect_call < limit_check < send_response
|
|
|
|
|
|
def test_folder_download_rejects_files():
|
|
src = ROUTES_PY.read_text(encoding="utf-8")
|
|
assert "path must be a directory" in src
|
|
assert "/api/file/raw" in src # error message guides user
|
|
|
|
|
|
def test_folder_download_streams_not_buffers():
|
|
src = ROUTES_PY.read_text(encoding="utf-8")
|
|
assert "zipfile.ZipFile(handler.wfile" in src
|
|
assert "allowZip64=True" in src
|
|
handler_idx = src.index("def _handle_folder_download")
|
|
end_idx = src.index("\n\ndef ", handler_idx + 1)
|
|
body = src[handler_idx:end_idx]
|
|
assert "io.BytesIO" not in body, "must stream, not buffer in memory"
|
|
|
|
|
|
def test_ui_context_menu_has_download_folder():
|
|
src = UI_JS.read_text(encoding="utf-8")
|
|
assert "download_folder" in src
|
|
download_idx = src.index("download_folder")
|
|
snippet = src[max(0, download_idx - 200):download_idx]
|
|
assert "item.type==='dir'" in snippet or "item.type === 'dir'" in snippet
|
|
|
|
|
|
def test_ui_download_folder_uses_endpoint():
|
|
src = UI_JS.read_text(encoding="utf-8")
|
|
download_idx = src.index("download_folder")
|
|
snippet = src[download_idx:download_idx + 600]
|
|
assert "/api/folder/download" in snippet
|
|
assert "session_id=" in snippet
|
|
assert "path=" in snippet
|
|
assert "encodeURIComponent" in snippet
|