left4me/l4d2web/tests/test_overlay_files_routes.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

367 lines
12 KiB
Python

"""HTTP-level tests for the overlay 'Files' tree-fragment + download routes."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Overlay, User
@pytest.fixture
def app(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'files-routes.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
flask_app = create_app(
{"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}
)
init_db()
return flask_app
@pytest.fixture
def left4me_root(tmp_path) -> Path:
return tmp_path
def _client_for(app, user_id: int):
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["csrf_token"] = "test-token"
return client
def _make_user(*, username: str = "alice", admin: bool = False) -> int:
with session_scope() as s:
user = User(
username=username, password_digest=hash_password("x"), admin=admin
)
s.add(user)
s.flush()
return user.id
def _make_overlay(left4me_root: Path, *, user_id: int | None, name: str) -> int:
"""Create an Overlay row + the matching `LEFT4ME_ROOT/overlays/{id}/`
directory, mirroring what `overlay_creation.create_overlay_directory`
would do in production."""
with session_scope() as s:
overlay = Overlay(name=name, path="", type="script", user_id=user_id)
s.add(overlay)
s.flush()
overlay.path = str(overlay.id)
overlay_id = overlay.id
(left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True)
return overlay_id
def test_files_fragment_lists_root_directory(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
(overlay_dir / "left4dead2").mkdir()
(overlay_dir / "readme.txt").write_text("hi")
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}/files")
assert response.status_code == 200
text = response.get_data(as_text=True)
assert "left4dead2" in text
assert "readme.txt" in text
def test_files_fragment_lists_subdirectory(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
addons = overlay_dir / "left4dead2" / "addons"
addons.mkdir(parents=True)
(addons / "deathcraft.vpk").write_text("vpk")
client = _client_for(app, user_id)
response = client.get(
f"/overlays/{overlay_id}/files?path=left4dead2/addons"
)
assert response.status_code == 200
text = response.get_data(as_text=True)
assert "deathcraft.vpk" in text
# Fragment, no full document.
assert "<html" not in text
def test_files_fragment_returns_400_on_dotdot(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}/files?path=../../etc")
assert response.status_code == 400
def test_files_fragment_returns_400_on_absolute_path(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}/files?path=/etc/passwd")
assert response.status_code == 400
def test_files_fragment_returns_404_for_unknown_overlay(app) -> None:
user_id = _make_user()
client = _client_for(app, user_id)
response = client.get("/overlays/9999/files")
assert response.status_code == 404
def test_files_fragment_returns_404_for_missing_subdir(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}/files?path=ghost")
assert response.status_code == 404
def test_files_fragment_returns_403_for_other_users_overlay(
app, left4me_root: Path
) -> None:
owner_id = _make_user(username="owner")
other_id = _make_user(username="other")
overlay_id = _make_overlay(left4me_root, user_id=owner_id, name="private")
client = _client_for(app, other_id)
response = client.get(f"/overlays/{overlay_id}/files")
assert response.status_code == 403
def test_admin_can_view_files_for_other_users_overlay(
app, left4me_root: Path
) -> None:
owner_id = _make_user(username="owner")
admin_id = _make_user(username="admin", admin=True)
overlay_id = _make_overlay(left4me_root, user_id=owner_id, name="private")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
(overlay_dir / "secret.cfg").write_text("k=v")
client = _client_for(app, admin_id)
response = client.get(f"/overlays/{overlay_id}/files")
assert response.status_code == 200
assert "secret.cfg" in response.get_data(as_text=True)
def test_download_streams_regular_file(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
cfg = overlay_dir / "cfg" / "server.cfg"
cfg.parent.mkdir()
cfg.write_bytes(b"hostname test")
client = _client_for(app, user_id)
response = client.get(
f"/overlays/{overlay_id}/files/download?path=cfg/server.cfg"
)
assert response.status_code == 200
assert response.headers["Content-Disposition"].startswith("attachment")
assert "filename=server.cfg" in response.headers["Content-Disposition"]
assert response.get_data() == b"hostname test"
def test_download_follows_workshop_cache_symlink(
app, left4me_root: Path
) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
cache_file = left4me_root / "workshop_cache" / "12345.vpk"
cache_file.parent.mkdir(parents=True)
cache_file.write_bytes(b"vpk-content")
addons = overlay_dir / "left4dead2" / "addons"
addons.mkdir(parents=True)
(addons / "deathcraft.vpk").symlink_to(cache_file)
client = _client_for(app, user_id)
response = client.get(
f"/overlays/{overlay_id}/files/download?path=left4dead2/addons/deathcraft.vpk"
)
assert response.status_code == 200
assert response.get_data() == b"vpk-content"
def test_download_rejects_symlink_outside_left4me_root(
app, left4me_root: Path, tmp_path_factory
) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
outside = tmp_path_factory.mktemp("outside") / "secret.txt"
outside.write_text("nope")
(overlay_dir / "evil").symlink_to(outside)
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}/files/download?path=evil")
assert response.status_code == 400
def test_download_rejects_directory_target(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
(overlay_dir / "left4dead2").mkdir()
client = _client_for(app, user_id)
response = client.get(
f"/overlays/{overlay_id}/files/download?path=left4dead2"
)
assert response.status_code == 400
def test_download_returns_404_for_missing_file(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
client = _client_for(app, user_id)
response = client.get(
f"/overlays/{overlay_id}/files/download?path=ghost.txt"
)
assert response.status_code == 404
def test_download_returns_403_for_other_users_overlay(
app, left4me_root: Path
) -> None:
owner_id = _make_user(username="owner")
other_id = _make_user(username="other")
overlay_id = _make_overlay(left4me_root, user_id=owner_id, name="private")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
(overlay_dir / "secret.cfg").write_text("nope")
client = _client_for(app, other_id)
response = client.get(
f"/overlays/{overlay_id}/files/download?path=secret.cfg"
)
assert response.status_code == 403
def test_files_fragment_truncates_at_cap(app, left4me_root: Path, monkeypatch) -> None:
"""The cap default is 500 — exercise it via the public route by lowering
it for this test through the helper module."""
from l4d2web.services import overlay_files as overlay_files_module
monkeypatch.setattr(overlay_files_module, "DEFAULT_MAX_ENTRIES", 5)
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
for i in range(8):
(overlay_dir / f"f{i}.txt").write_text("x")
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}/files")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "+ 3 more" in text
def test_overlay_detail_renders_files_section_with_tree(
app, left4me_root: Path
) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
(overlay_dir / "left4dead2").mkdir()
(overlay_dir / "readme.txt").write_text("hi")
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Files" in text
assert "left4dead2" in text
assert "readme.txt" in text
def test_overlay_detail_shows_empty_state_when_overlay_dir_missing(
app, left4me_root: Path
) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
# Wipe the directory created by _make_overlay so the on-disk dir is gone.
overlay_dir = left4me_root / "overlays" / str(overlay_id)
overlay_dir.rmdir()
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "No files yet" in text
def test_overlay_detail_files_section_present_for_workshop_overlays(
app, left4me_root: Path
) -> None:
user_id = _make_user()
# Create a workshop overlay manually since _make_overlay defaults to script.
with session_scope() as s:
overlay = Overlay(name="ws", path="", type="workshop", user_id=user_id)
s.add(overlay)
s.flush()
overlay.path = str(overlay.id)
overlay_id = overlay.id
(left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True)
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
# Section heading present even when the overlay dir is empty.
assert "Files" in text
def test_files_fragment_renders_broken_symlink_without_download_link(
app, left4me_root: Path
) -> None:
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
(overlay_dir / "missing.vpk").symlink_to(overlay_dir / "does-not-exist.vpk")
client = _client_for(app, user_id)
response = client.get(f"/overlays/{overlay_id}/files")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "missing.vpk" in text
assert "broken" in text
# No download link for broken symlinks.
assert (
f'href="/overlays/{overlay_id}/files/download?path=missing.vpk"' not in text
)