"""Tests for overlay builders (registry, WorkshopBuilder, ScriptBuilder).""" from __future__ import annotations import os from datetime import UTC, datetime from pathlib import Path from types import SimpleNamespace import pytest from l4d2web.db import init_db, session_scope from l4d2web.models import Overlay, OverlayWorkshopItem, User, WorkshopItem from l4d2web.services import overlay_builders from l4d2web.services.host_commands import CommandCancelledError, CommandResult @pytest.fixture def env(tmp_path, monkeypatch): monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'b.db'}") monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) init_db() yield tmp_path def _create_user_and_overlay(name: str, type_: str) -> tuple[int, int]: with session_scope() as s: user = User(username="alice", password_digest="x") s.add(user) s.flush() overlay = Overlay(name=name, path=str(7), type=type_, user_id=user.id) s.add(overlay) s.flush() return user.id, overlay.id def _add_workshop_item(steam_id: str, *, downloaded: bool, cache_root: Path, content: bytes = b"x") -> int: if downloaded: cache_root.mkdir(parents=True, exist_ok=True) (cache_root / f"{steam_id}.vpk").write_bytes(content) os.utime(cache_root / f"{steam_id}.vpk", (1700000000, 1700000000)) with session_scope() as s: wi = WorkshopItem( steam_id=steam_id, title=f"item-{steam_id}", filename=f"orig-{steam_id}.vpk", file_url=f"https://example.com/{steam_id}.vpk", file_size=len(content) if downloaded else 0, time_updated=1700000000 if downloaded else 0, last_downloaded_at=datetime.now(UTC) if downloaded else None, ) s.add(wi) s.flush() return wi.id def _associate(overlay_id: int, item_id: int) -> None: with session_scope() as s: s.add(OverlayWorkshopItem(overlay_id=overlay_id, workshop_item_id=item_id)) def _capture_logs(): out: list[str] = [] err: list[str] = [] return out, err, out.append, err.append def test_builders_registry() -> None: assert set(overlay_builders.BUILDERS) == {"workshop", "script", "files"} def test_registry_excludes_legacy_types() -> None: for legacy in ("external", "l4d2center_maps", "cedapug_maps"): assert legacy not in overlay_builders.BUILDERS def test_files_builder_is_idempotent_no_op(monkeypatch, tmp_path) -> None: """Files builder ensures the overlay directory exists. Running twice against an already-populated overlay must not clobber its contents.""" monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) overlay = type("O", (), {"id": 42, "name": "files-fixture"})() out, err, on_stdout, on_stderr = _capture_logs() overlay_builders.BUILDERS["files"].build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False ) overlay_dir = tmp_path / "overlays" / "42" assert overlay_dir.is_dir() (overlay_dir / "kept.txt").write_text("preserved") overlay_builders.BUILDERS["files"].build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False ) assert (overlay_dir / "kept.txt").read_text() == "preserved" assert err == [] def test_registry_unknown_type_raises_keyerror() -> None: with pytest.raises(KeyError): overlay_builders.BUILDERS["nope"] def test_workshop_builder_unchanged() -> None: """Regression guard against accidental removal during refactor.""" builder = overlay_builders.BUILDERS["workshop"] assert isinstance(builder, overlay_builders.WorkshopBuilder) assert hasattr(builder, "build") def test_workshop_builder_creates_absolute_symlinks(env: Path) -> None: _, overlay_id = _create_user_and_overlay("ws", "workshop") cache_root = env / "workshop_cache" item_a = _add_workshop_item("1001", downloaded=True, cache_root=cache_root, content=b"AAA") item_b = _add_workshop_item("1002", downloaded=True, cache_root=cache_root, content=b"BBBB") _associate(overlay_id, item_a) _associate(overlay_id, item_b) out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: overlay = s.query(Overlay).filter_by(id=overlay_id).one() overlay_builders.BUILDERS["workshop"].build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False ) addons = env / "overlays" / "7" / "left4dead2" / "addons" link_a = addons / "1001.vpk" link_b = addons / "1002.vpk" assert link_a.is_symlink() assert link_b.is_symlink() # Targets must be ABSOLUTE so they resolve in the host's namespace. assert os.path.isabs(os.readlink(link_a)) assert os.path.isabs(os.readlink(link_b)) # And they must resolve to the cache files. assert link_a.resolve() == (cache_root / "1001.vpk").resolve() assert link_b.resolve() == (cache_root / "1002.vpk").resolve() def test_workshop_builder_rerun_is_idempotent(env: Path) -> None: _, overlay_id = _create_user_and_overlay("ws", "workshop") cache_root = env / "workshop_cache" item = _add_workshop_item("1001", downloaded=True, cache_root=cache_root) _associate(overlay_id, item) addons = env / "overlays" / "7" / "left4dead2" / "addons" # First run. with session_scope() as s: overlay = s.query(Overlay).filter_by(id=overlay_id).one() overlay_builders.BUILDERS["workshop"].build(overlay, on_stdout=lambda _x: None, on_stderr=lambda _x: None, should_cancel=lambda: False) first_inode = (addons / "1001.vpk").lstat().st_ino # Second run — no-op. out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: overlay = s.query(Overlay).filter_by(id=overlay_id).one() overlay_builders.BUILDERS["workshop"].build(overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False) second_inode = (addons / "1001.vpk").lstat().st_ino assert first_inode == second_inode, "symlink should be untouched on idempotent rebuild" assert any("unchanged" in line.lower() for line in out), out def test_workshop_builder_removes_obsolete_symlinks(env: Path) -> None: _, overlay_id = _create_user_and_overlay("ws", "workshop") cache_root = env / "workshop_cache" item_a = _add_workshop_item("1001", downloaded=True, cache_root=cache_root) item_b = _add_workshop_item("1002", downloaded=True, cache_root=cache_root) _associate(overlay_id, item_a) _associate(overlay_id, item_b) addons = env / "overlays" / "7" / "left4dead2" / "addons" with session_scope() as s: overlay = s.query(Overlay).filter_by(id=overlay_id).one() overlay_builders.BUILDERS["workshop"].build(overlay, on_stdout=lambda _x: None, on_stderr=lambda _x: None, should_cancel=lambda: False) assert (addons / "1002.vpk").is_symlink() # Remove the association for 1002. with session_scope() as s: s.query(OverlayWorkshopItem).filter_by(workshop_item_id=item_b).delete() out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: overlay = s.query(Overlay).filter_by(id=overlay_id).one() overlay_builders.BUILDERS["workshop"].build(overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False) assert (addons / "1001.vpk").is_symlink() assert not (addons / "1002.vpk").exists() # Cache file must remain — overlays are diff-applied, cache is shared. assert (cache_root / "1002.vpk").exists() def test_workshop_builder_leaves_unrelated_files_alone(env: Path) -> None: _, overlay_id = _create_user_and_overlay("ws", "workshop") cache_root = env / "workshop_cache" item = _add_workshop_item("1001", downloaded=True, cache_root=cache_root) _associate(overlay_id, item) addons = env / "overlays" / "7" / "left4dead2" / "addons" addons.mkdir(parents=True, exist_ok=True) (addons / "manual_addon.vpk").write_bytes(b"hand-placed") with session_scope() as s: overlay = s.query(Overlay).filter_by(id=overlay_id).one() overlay_builders.BUILDERS["workshop"].build(overlay, on_stdout=lambda _x: None, on_stderr=lambda _x: None, should_cancel=lambda: False) # Manual file is preserved. assert (addons / "manual_addon.vpk").read_bytes() == b"hand-placed" # Workshop symlink is created alongside. assert (addons / "1001.vpk").is_symlink() def test_workshop_builder_honors_should_cancel(env: Path) -> None: _, overlay_id = _create_user_and_overlay("ws", "workshop") cache_root = env / "workshop_cache" items = [_add_workshop_item(f"100{i}", downloaded=True, cache_root=cache_root) for i in range(3)] for it in items: _associate(overlay_id, it) cancel_calls = {"n": 0} def cancel(): cancel_calls["n"] += 1 return cancel_calls["n"] > 0 # cancel immediately with session_scope() as s: overlay = s.query(Overlay).filter_by(id=overlay_id).one() # Should not crash; partial state is consistent (re-run heals). overlay_builders.BUILDERS["workshop"].build( overlay, on_stdout=lambda _x: None, on_stderr=lambda _x: None, should_cancel=cancel ) # --- ScriptBuilder --------------------------------------------------------- def _script_overlay(*, id_: int = 42, script: str = "echo hi") -> SimpleNamespace: return SimpleNamespace(id=id_, type="script", path=str(id_), script=script) def test_script_builder_invokes_helper(env, monkeypatch) -> None: captured: dict = {} def fake_run(cmd, *, on_stdout, on_stderr, should_cancel): captured["cmd"] = list(cmd) captured["script_text"] = open(cmd[-1]).read() captured["script_path_existed"] = os.path.exists(cmd[-1]) return CommandResult(returncode=0, stdout="", stderr="") monkeypatch.setattr(overlay_builders, "run_command", fake_run) monkeypatch.setattr( overlay_builders.ScriptBuilder, "_enforce_disk_budget", lambda *a, **kw: None ) overlay = _script_overlay() overlay_builders.ScriptBuilder().build( overlay, on_stdout=lambda _x: None, on_stderr=lambda _x: None, should_cancel=lambda: False, ) assert captured["cmd"][:4] == [ "sudo", "-n", "/usr/local/libexec/left4me/left4me-script-sandbox", "42", ] assert captured["script_text"] == "echo hi" assert captured["script_path_existed"] is True # Tmpfile is unlinked after build. assert not os.path.exists(captured["cmd"][-1]) def test_script_builder_disk_cap(env, monkeypatch) -> None: monkeypatch.setattr( overlay_builders, "run_command", lambda *a, **kw: CommandResult(returncode=0, stdout="", stderr=""), ) monkeypatch.setattr( overlay_builders.subprocess, "check_output", lambda *a, **kw: b"25000000000\t/some/path\n", ) err: list[str] = [] overlay = _script_overlay(script="") with pytest.raises(overlay_builders.BuildError): overlay_builders.ScriptBuilder().build( overlay, on_stdout=lambda _x: None, on_stderr=err.append, should_cancel=lambda: False, ) assert any("20" in line and "GB" in line for line in err), err def test_script_builder_streams_output(env, monkeypatch) -> None: def fake_run(cmd, *, on_stdout, on_stderr, should_cancel): on_stdout("hello") on_stderr("warn") return CommandResult(returncode=0, stdout="hello", stderr="warn") monkeypatch.setattr(overlay_builders, "run_command", fake_run) monkeypatch.setattr( overlay_builders.ScriptBuilder, "_enforce_disk_budget", lambda *a, **kw: None ) out: list[str] = [] err: list[str] = [] overlay = _script_overlay(script="") overlay_builders.ScriptBuilder().build( overlay, on_stdout=out.append, on_stderr=err.append, should_cancel=lambda: False ) assert out == ["hello"] assert err == ["warn"] def test_script_builder_passes_should_cancel_through(env, monkeypatch) -> None: captured: dict = {} def fake_run(cmd, *, on_stdout, on_stderr, should_cancel): captured["should_cancel"] = should_cancel raise CommandCancelledError(returncode=1, cmd=cmd, output="", stderr="") monkeypatch.setattr(overlay_builders, "run_command", fake_run) monkeypatch.setattr( overlay_builders.ScriptBuilder, "_enforce_disk_budget", lambda *a, **kw: None ) overlay = _script_overlay(script="") with pytest.raises(CommandCancelledError): overlay_builders.ScriptBuilder().build( overlay, on_stdout=lambda _x: None, on_stderr=lambda _x: None, should_cancel=lambda: True, ) assert captured["should_cancel"]() is True def test_script_builder_cleans_up_tmpfile_on_failure(env, monkeypatch) -> None: captured: dict = {} def fake_run(cmd, *, on_stdout, on_stderr, should_cancel): captured["script_path"] = cmd[-1] raise CommandCancelledError(returncode=1, cmd=cmd, output="", stderr="") monkeypatch.setattr(overlay_builders, "run_command", fake_run) overlay = _script_overlay(script="") with pytest.raises(CommandCancelledError): overlay_builders.ScriptBuilder().build( overlay, on_stdout=lambda _x: None, on_stderr=lambda _x: None, should_cancel=lambda: False, ) assert not os.path.exists(captured["script_path"]) def test_sleep_with_cancel_returns_normally_when_not_cancelled(): from l4d2web.services.overlay_builders import _sleep_with_cancel cancelled = _sleep_with_cancel(0.05, lambda: False, poll_interval=0.01) assert cancelled is False def test_sleep_with_cancel_returns_early_when_cancelled(): import time from l4d2web.services.overlay_builders import _sleep_with_cancel flag = {"cancel": False} def cancel_check(): return flag["cancel"] import threading threading.Timer(0.05, lambda: flag.update(cancel=True)).start() start = time.monotonic() cancelled = _sleep_with_cancel(5.0, cancel_check, poll_interval=0.01) elapsed = time.monotonic() - start assert cancelled is True assert elapsed < 0.5, f"should have woken up promptly, slept {elapsed:.3f}s" def test_download_with_retry_succeeds_on_first_attempt(env, tmp_path, monkeypatch): from l4d2web.services import overlay_builders, steam_workshop monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False) calls = [] def fake_download(meta, cache_root, *, should_cancel=None): calls.append(1) return cache_root / f"{meta.steam_id}.vpk" monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download) meta = steam_workshop.WorkshopMetadata( steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk", file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1, ) out, err, on_stdout, on_stderr = _capture_logs() overlay_builders._download_with_retry( meta, tmp_path / "cache", on_stderr=on_stderr, should_cancel=lambda: False, ) assert calls == [1] assert err == [] def test_download_with_retry_retries_then_succeeds(env, tmp_path, monkeypatch): import requests from l4d2web.services import overlay_builders, steam_workshop monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False) attempts = {"n": 0} def fake_download(meta, cache_root, *, should_cancel=None): attempts["n"] += 1 if attempts["n"] < 3: raise requests.ConnectionError("boom") monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download) meta = steam_workshop.WorkshopMetadata( steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk", file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1, ) out, err, on_stdout, on_stderr = _capture_logs() overlay_builders._download_with_retry( meta, tmp_path / "cache", on_stderr=on_stderr, should_cancel=lambda: False, ) assert attempts["n"] == 3 assert sum(1 for line in err if "attempt" in line and "failed" in line) == 2 def test_download_with_retry_exhausts_and_raises(env, tmp_path, monkeypatch): import requests from l4d2web.services import overlay_builders, steam_workshop monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False) def fake_download(meta, cache_root, *, should_cancel=None): raise requests.ConnectionError("permanent") monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download) meta = steam_workshop.WorkshopMetadata( steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk", file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1, ) out, err, on_stdout, on_stderr = _capture_logs() with pytest.raises(requests.ConnectionError): overlay_builders._download_with_retry( meta, tmp_path / "cache", on_stderr=on_stderr, should_cancel=lambda: False, ) # Two stderr "attempt N/3 failed" lines for attempts 1 and 2; the final # attempt re-raises without logging. assert sum(1 for line in err if "attempt" in line and "failed" in line) == 2 def test_download_with_retry_propagates_interrupted(env, tmp_path, monkeypatch): from l4d2web.services import overlay_builders, steam_workshop def fake_download(meta, cache_root, *, should_cancel=None): raise InterruptedError("cancelled") monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download) meta = steam_workshop.WorkshopMetadata( steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk", file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1, ) out, err, on_stdout, on_stderr = _capture_logs() with pytest.raises(InterruptedError): overlay_builders._download_with_retry( meta, tmp_path / "cache", on_stderr=on_stderr, should_cancel=lambda: False, ) def test_download_with_retry_bails_when_cancelled_during_backoff(env, tmp_path, monkeypatch): import requests from l4d2web.services import overlay_builders, steam_workshop def fake_download(meta, cache_root, *, should_cancel=None): raise requests.ConnectionError("boom") monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download) monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: True) meta = steam_workshop.WorkshopMetadata( steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk", file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1, ) out, err, on_stdout, on_stderr = _capture_logs() with pytest.raises(InterruptedError): overlay_builders._download_with_retry( meta, tmp_path / "cache", on_stderr=on_stderr, should_cancel=lambda: False, ) def _make_meta_from_db_row(steam_id: str, *, file_size: int, time_updated: int): from l4d2web.services import steam_workshop return steam_workshop.WorkshopMetadata( steam_id=steam_id, title=f"item-{steam_id}", filename=f"orig-{steam_id}.vpk", file_url=f"https://example.com/{steam_id}.vpk", file_size=file_size, time_updated=time_updated, preview_url="", consumer_app_id=550, result=1, ) def test_workshop_build_downloads_uncached_and_stamps_timestamp(env, tmp_path, monkeypatch): monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) cache_root = tmp_path / "workshop_cache" user_id, overlay_id = _create_user_and_overlay("ws", "workshop") item_id = _add_workshop_item("2001", downloaded=False, cache_root=cache_root) _associate(overlay_id, item_id) download_calls = [] def fake_download(meta, cache_root_arg, *, should_cancel=None): download_calls.append(meta.steam_id) cache_root_arg.mkdir(parents=True, exist_ok=True) (cache_root_arg / f"{meta.steam_id}.vpk").write_bytes(b"data") os.utime(cache_root_arg / f"{meta.steam_id}.vpk", (meta.time_updated, meta.time_updated)) monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download) out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: overlay = s.scalar(__import__("sqlalchemy").select(Overlay).where(Overlay.id == overlay_id)) s.expunge(overlay) overlay_builders.WorkshopBuilder().build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False, ) assert download_calls == ["2001"] with session_scope() as s: from sqlalchemy import select as _select wi = s.scalar(_select(WorkshopItem).where(WorkshopItem.id == item_id)) assert wi.last_downloaded_at is not None assert wi.last_error == "" addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons" assert (addons / "2001.vpk").is_symlink() def test_workshop_build_skips_already_cached(env, tmp_path, monkeypatch): monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) cache_root = tmp_path / "workshop_cache" user_id, overlay_id = _create_user_and_overlay("ws", "workshop") item_id = _add_workshop_item("2002", downloaded=True, cache_root=cache_root) # Make the cache file's (mtime, size) match the DB row exactly. file_path = cache_root / "2002.vpk" os.utime(file_path, (1700000000, 1700000000)) with session_scope() as s: from sqlalchemy import select as _sel, update as _upd s.execute(_upd(WorkshopItem).where(WorkshopItem.id == item_id).values( file_size=os.path.getsize(file_path), time_updated=1700000000, )) _associate(overlay_id, item_id) called = [] monkeypatch.setattr( overlay_builders, "download_to_cache", lambda *a, **kw: called.append(1), ) out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: from sqlalchemy import select as _sel overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id)) s.expunge(overlay) overlay_builders.WorkshopBuilder().build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False, ) assert called == [], "should not call downloader for an already-cached item" addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons" assert (addons / "2002.vpk").is_symlink() def test_workshop_build_redownloads_stale_cache(env, tmp_path, monkeypatch): monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) cache_root = tmp_path / "workshop_cache" user_id, overlay_id = _create_user_and_overlay("ws", "workshop") item_id = _add_workshop_item("2003", downloaded=True, cache_root=cache_root) with session_scope() as s: from sqlalchemy import update as _upd s.execute(_upd(WorkshopItem).where(WorkshopItem.id == item_id).values( file_size=99, time_updated=1800000000, )) _associate(overlay_id, item_id) download_calls = [] def fake_download(meta, cache_root_arg, *, should_cancel=None): download_calls.append(meta.steam_id) (cache_root_arg / f"{meta.steam_id}.vpk").write_bytes(b"99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____") os.utime(cache_root_arg / f"{meta.steam_id}.vpk", (meta.time_updated, meta.time_updated)) monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download) out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: from sqlalchemy import select as _sel overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id)) s.expunge(overlay) overlay_builders.WorkshopBuilder().build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False, ) assert download_calls == ["2003"] def test_workshop_build_skips_items_with_no_file_url(env, tmp_path, monkeypatch): monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) user_id, overlay_id = _create_user_and_overlay("ws", "workshop") with session_scope() as s: wi = WorkshopItem( steam_id="2004", title="gone", filename="", file_url="", file_size=0, time_updated=0, preview_url="", last_downloaded_at=None, last_error="steam result 9", ) s.add(wi) s.flush() item_id = wi.id _associate(overlay_id, item_id) monkeypatch.setattr( overlay_builders, "download_to_cache", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("must not be called")), ) out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: from sqlalchemy import select as _sel overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id)) s.expunge(overlay) overlay_builders.WorkshopBuilder().build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False, ) assert any("2004" in line and "skipped" in line for line in err) addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons" assert not (addons / "2004.vpk").exists() def test_workshop_build_fails_when_all_retries_exhausted(env, tmp_path, monkeypatch): import requests monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) user_id, overlay_id = _create_user_and_overlay("ws", "workshop") item_id = _add_workshop_item("2005", downloaded=False, cache_root=tmp_path / "workshop_cache") _associate(overlay_id, item_id) monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False) monkeypatch.setattr( overlay_builders, "download_to_cache", lambda *a, **kw: (_ for _ in ()).throw(requests.ConnectionError("net")), ) out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: from sqlalchemy import select as _sel overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id)) s.expunge(overlay) with pytest.raises(requests.ConnectionError): overlay_builders.WorkshopBuilder().build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False, ) with session_scope() as s: from sqlalchemy import select as _sel wi = s.scalar(_sel(WorkshopItem).where(WorkshopItem.id == item_id)) assert "download failed" in wi.last_error def test_workshop_build_cancels_cleanly_during_download_phase(env, tmp_path, monkeypatch): monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) user_id, overlay_id = _create_user_and_overlay("ws", "workshop") item_id = _add_workshop_item("2006", downloaded=False, cache_root=tmp_path / "workshop_cache") _associate(overlay_id, item_id) cancel_flag = {"v": False} def fake_download(meta, cache_root, *, should_cancel=None): cancel_flag["v"] = True raise InterruptedError("cancelled") monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download) out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: from sqlalchemy import select as _sel overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id)) s.expunge(overlay) with pytest.raises(InterruptedError): overlay_builders.WorkshopBuilder().build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: cancel_flag["v"], ) def test_workshop_build_refuses_to_overwrite_non_symlink_file(env, tmp_path, monkeypatch): """If a plain file collides with a workshop symlink name, the build logs a refusal and leaves the file alone instead of crashing.""" monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) cache_root = tmp_path / "workshop_cache" user_id, overlay_id = _create_user_and_overlay("ws", "workshop") item_id = _add_workshop_item("3001", downloaded=True, cache_root=cache_root) _associate(overlay_id, item_id) # Pre-create a plain file (not a symlink) where the builder would place its symlink. addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons" addons.mkdir(parents=True, exist_ok=True) (addons / "3001.vpk").write_bytes(b"manual file, don't touch") out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: from sqlalchemy import select as _sel overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id)) s.expunge(overlay) overlay_builders.WorkshopBuilder().build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False, ) # The plain file is still there, unchanged. assert (addons / "3001.vpk").exists() assert not (addons / "3001.vpk").is_symlink() assert (addons / "3001.vpk").read_bytes() == b"manual file, don't touch" # And a refusal message was logged. assert any("refusing to overwrite non-symlink" in line for line in err) def test_workshop_build_refuses_to_overwrite_foreign_symlink(env, tmp_path, monkeypatch): """A symlink pointing outside the workshop cache is left alone — not overwritten, not failed.""" monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) cache_root = tmp_path / "workshop_cache" user_id, overlay_id = _create_user_and_overlay("ws", "workshop") item_id = _add_workshop_item("3002", downloaded=True, cache_root=cache_root) _associate(overlay_id, item_id) # Pre-create a symlink pointing outside the cache root. foreign_target = tmp_path / "elsewhere" / "thing.vpk" foreign_target.parent.mkdir(parents=True, exist_ok=True) foreign_target.write_bytes(b"some other vpk") addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons" addons.mkdir(parents=True, exist_ok=True) os.symlink(foreign_target, addons / "3002.vpk") out, err, on_stdout, on_stderr = _capture_logs() with session_scope() as s: from sqlalchemy import select as _sel overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id)) s.expunge(overlay) overlay_builders.WorkshopBuilder().build( overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False, ) # The foreign symlink still points where it did. assert (addons / "3002.vpk").is_symlink() assert os.readlink(addons / "3002.vpk") == str(foreign_target) assert any("refusing to overwrite foreign symlink" in line for line in err)