left4me/l4d2web/tests/test_overlay_files.py
mwiegand a11d030edd
feat(l4d2-web): overlay detail Files section with HTMX file tree + downloads
Adds a server-rendered collapsible file tree section to the overlay
detail page so users can verify what their script/workshop overlays
produced and pull individual artifacts (VPKs, configs) without SSH.
HTMX-driven lazy folder expansion with click-to-download via send_file;
symlinks land anywhere under LEFT4ME_ROOT (so workshop addons stream
from the shared cache) but escapes are refused. Same access rule as the
rest of the page (admin or owner). 39 new tests; full web suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:16:25 +02:00

267 lines
8.7 KiB
Python

"""Pure-helper tests for l4d2web.services.overlay_files.
Covers path safety (listing vs download), directory listing semantics,
symlink handling, and the children cap. Flask-free; only exercises the
filesystem.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
@pytest.fixture
def overlay_root(monkeypatch, tmp_path: Path) -> Path:
"""LEFT4ME_ROOT/overlays/7/ ready for tests to populate. Returns the overlay root."""
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
root = tmp_path / "overlays" / "7"
root.mkdir(parents=True)
return root
def test_safe_resolve_for_listing_returns_overlay_root_when_sub_path_empty(
overlay_root: Path,
) -> None:
from l4d2web.services.overlay_files import safe_resolve_for_listing
resolved = safe_resolve_for_listing("7", "")
assert resolved == overlay_root.resolve()
def test_safe_resolve_for_listing_joins_sub_path_under_overlay_root(
overlay_root: Path,
) -> None:
from l4d2web.services.overlay_files import safe_resolve_for_listing
(overlay_root / "left4dead2" / "addons").mkdir(parents=True)
resolved = safe_resolve_for_listing("7", "left4dead2/addons")
assert resolved == (overlay_root / "left4dead2" / "addons").resolve()
def test_safe_resolve_for_listing_rejects_dotdot(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import safe_resolve_for_listing
with pytest.raises(ValueError):
safe_resolve_for_listing("7", "../../etc")
def test_safe_resolve_for_listing_rejects_absolute_path(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import safe_resolve_for_listing
with pytest.raises(ValueError):
safe_resolve_for_listing("7", "/etc/passwd")
def test_safe_resolve_for_listing_rejects_empty_component(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import safe_resolve_for_listing
with pytest.raises(ValueError):
safe_resolve_for_listing("7", "foo//bar")
def test_safe_resolve_for_listing_rejects_symlink_escaping_overlay_root(
overlay_root: Path, tmp_path: Path
) -> None:
"""A directory inside the overlay that is itself a symlink to somewhere
outside the overlay must be refused — even if the target sits within
LEFT4ME_ROOT/workshop_cache."""
from l4d2web.services.overlay_files import safe_resolve_for_listing
cache = tmp_path / "workshop_cache" / "shared"
cache.mkdir(parents=True)
(overlay_root / "shared").symlink_to(cache)
with pytest.raises(ValueError):
safe_resolve_for_listing("7", "shared")
def test_safe_resolve_for_download_rejects_empty_path(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import safe_resolve_for_download
with pytest.raises(ValueError):
safe_resolve_for_download("7", "")
def test_safe_resolve_for_download_returns_real_path_for_regular_file(
overlay_root: Path,
) -> None:
from l4d2web.services.overlay_files import safe_resolve_for_download
target = overlay_root / "cfg" / "server.cfg"
target.parent.mkdir()
target.write_text("hostname test\n")
resolved = safe_resolve_for_download("7", "cfg/server.cfg")
assert resolved == target.resolve()
def test_safe_resolve_for_download_follows_symlink_into_workshop_cache(
overlay_root: Path, tmp_path: Path
) -> None:
"""Workshop overlays populate addons/*.vpk as symlinks into
LEFT4ME_ROOT/workshop_cache/. Download must follow the symlink and
return the cache target."""
from l4d2web.services.overlay_files import safe_resolve_for_download
cache_file = tmp_path / "workshop_cache" / "12345.vpk"
cache_file.parent.mkdir(parents=True)
cache_file.write_bytes(b"vpk-bytes")
addons = overlay_root / "left4dead2" / "addons"
addons.mkdir(parents=True)
(addons / "deathcraft.vpk").symlink_to(cache_file)
resolved = safe_resolve_for_download("7", "left4dead2/addons/deathcraft.vpk")
assert resolved == cache_file.resolve()
def test_safe_resolve_for_download_rejects_symlink_outside_left4me_root(
overlay_root: Path, tmp_path: Path
) -> None:
"""A malicious script overlay that plants a symlink at evil → /etc/passwd
must be refused."""
from l4d2web.services.overlay_files import safe_resolve_for_download
outside = tmp_path.parent / "outside-left4me-root.txt"
outside.write_text("nope")
(overlay_root / "evil").symlink_to(outside)
with pytest.raises(ValueError):
safe_resolve_for_download("7", "evil")
def test_safe_resolve_for_download_rejects_dotdot(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import safe_resolve_for_download
with pytest.raises(ValueError):
safe_resolve_for_download("7", "../../etc/passwd")
def test_safe_resolve_for_download_rejects_absolute_path(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import safe_resolve_for_download
with pytest.raises(ValueError):
safe_resolve_for_download("7", "/etc/passwd")
def test_list_directory_returns_empty_for_empty_dir(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import list_directory
entries, truncated = list_directory(overlay_root, overlay_root)
assert entries == []
assert truncated == 0
def test_list_directory_returns_dirs_before_files_alphabetically(
overlay_root: Path,
) -> None:
from l4d2web.services.overlay_files import list_directory
(overlay_root / "Zeta").mkdir()
(overlay_root / "alpha").mkdir()
(overlay_root / "Beta-file.txt").write_text("b")
(overlay_root / "alpha-file.txt").write_text("a")
entries, _ = list_directory(overlay_root, overlay_root)
names = [e["name"] for e in entries]
# Dirs first (case-insensitive alpha), then files (case-insensitive alpha).
assert names == ["alpha", "Zeta", "alpha-file.txt", "Beta-file.txt"]
def test_list_directory_marks_kind_dir_or_file(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import list_directory
(overlay_root / "subdir").mkdir()
(overlay_root / "leaf.txt").write_text("hi")
entries, _ = list_directory(overlay_root, overlay_root)
by_name = {e["name"]: e for e in entries}
assert by_name["subdir"]["kind"] == "dir"
assert by_name["leaf.txt"]["kind"] == "file"
def test_list_directory_includes_relative_path_under_overlay_root(
overlay_root: Path,
) -> None:
from l4d2web.services.overlay_files import list_directory
addons = overlay_root / "left4dead2" / "addons"
addons.mkdir(parents=True)
(addons / "foo.vpk").write_text("v")
entries, _ = list_directory(addons, overlay_root)
assert entries[0]["rel"] == "left4dead2/addons/foo.vpk"
def test_list_directory_marks_symlinks_with_resolved_size(
overlay_root: Path, tmp_path: Path
) -> None:
from l4d2web.services.overlay_files import list_directory
cache_file = tmp_path / "workshop_cache" / "12345.vpk"
cache_file.parent.mkdir(parents=True)
cache_file.write_bytes(b"x" * 4096)
addons = overlay_root / "left4dead2" / "addons"
addons.mkdir(parents=True)
(addons / "deathcraft.vpk").symlink_to(cache_file)
entries, _ = list_directory(addons, overlay_root)
entry = entries[0]
assert entry["is_symlink"] is True
assert entry["broken"] is False
assert entry["size"] == 4096
assert entry["kind"] == "file"
def test_list_directory_marks_broken_symlinks(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import list_directory
(overlay_root / "missing.vpk").symlink_to(overlay_root / "does-not-exist.vpk")
entries, _ = list_directory(overlay_root, overlay_root)
entry = entries[0]
assert entry["is_symlink"] is True
assert entry["broken"] is True
assert entry["size"] is None
assert entry["kind"] == "file"
def test_list_directory_truncates_at_cap(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import list_directory
for i in range(7):
(overlay_root / f"f{i:02d}.txt").write_text("x")
entries, truncated = list_directory(overlay_root, overlay_root, max_entries=5)
assert len(entries) == 5
assert truncated == 2
assert [e["name"] for e in entries] == [f"f{i:02d}.txt" for i in range(5)]
def test_list_directory_includes_size_human_for_files(overlay_root: Path) -> None:
from l4d2web.services.overlay_files import list_directory
(overlay_root / "tiny.txt").write_text("hello")
(overlay_root / "big.bin").write_bytes(b"x" * (3 * 1024 * 1024))
entries, _ = list_directory(overlay_root, overlay_root)
by_name = {e["name"]: e for e in entries}
# Files only — directories don't have size_human.
assert by_name["tiny.txt"]["size_human"] == "5 B"
assert by_name["big.bin"]["size_human"] == "3.0 MB"