"""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 " 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 )