"""Pytest fixtures for end-to-end browser tests. Boots the Flask app in a background thread on an ephemeral port and yields the base URL. The app uses a temp SQLite DB so e2e runs don't contaminate the dev database. """ import socket import threading import pytest from werkzeug.serving import make_server from l4d2web.app import create_app from l4d2web.auth import hash_password from l4d2web.db import init_db, session_scope from l4d2web.models import Blueprint, Overlay, Server, User def _free_port() -> int: s = socket.socket() s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] s.close() return port def _boot_app(tmp_path, monkeypatch): """Set the env vars + create_app + init_db combo shared by every e2e fixture. Returns the Flask app. Sets DATABASE_URL → temp sqlite; SESSION_COOKIE_SECURE=0 so the browser keeps the session cookie over http://127.0.0.1 (app.py:57 would otherwise mark it Secure). Caller-specific env vars (e.g. LEFT4ME_ROOT for files-overlay tests) must be set BEFORE calling this, since create_app reads them during app construction. """ db_path = tmp_path / "e2e.db" db_url = f"sqlite:///{db_path}" monkeypatch.setenv("DATABASE_URL", db_url) monkeypatch.setenv("SESSION_COOKIE_SECURE", "0") app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"}) # create_app() already calls init_db() inside an app context, which # binds tables to the in-app engine. The seed work below uses # session_scope() OUTSIDE any app context, which reads DATABASE_URL # from the environment and binds its own engine. This second init_db() # call creates the tables on that env-derived engine so the seed # inserts have somewhere to land. init_db() return app def _serve(app): """Start the app on a background thread, return (base_url, shutdown). Caller is responsible for calling shutdown() in a finally block. """ port = _free_port() server = make_server("127.0.0.1", port, app) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() def shutdown(): server.shutdown() thread.join(timeout=2) return f"http://127.0.0.1:{port}", shutdown def login(page, base_url: str, username: str = "alice", password: str = "secret") -> None: """Form-POST login helper. Reused by every e2e test.""" page.goto(f"{base_url}/login") page.fill('input[name="username"]', username) page.fill('input[name="password"]', password) page.click('button[type="submit"]') @pytest.fixture(scope="function") def live_server(tmp_path, monkeypatch): # Some routes (e.g. POST /overlays via create_overlay_directory) write # under $LEFT4ME_ROOT. Point it at tmp_path so the prod default # /var/lib/left4me doesn't kick in on dev machines. monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) app = _boot_app(tmp_path, monkeypatch) with session_scope() as session: user = User( username="alice", password_digest=hash_password("secret"), admin=False, ) session.add(user) session.flush() bp = Blueprint( user_id=user.id, name="bp", arguments="[]", config="[]" ) session.add(bp) session.flush() blueprint_id = bp.id user_id = user.id base_url, shutdown = _serve(app) try: yield { "base_url": base_url, "user_id": user_id, "blueprint_id": blueprint_id, } finally: shutdown() @pytest.fixture(scope="function") def files_overlay_server(tmp_path, monkeypatch): """live_server + a files-type Overlay owned by alice, seeded with: /server.cfg (text, editable) /icon.png (binary, replaceable) /cfg/admins.txt (nested-folder fixture) LEFT4ME_ROOT is monkey-patched to tmp_path BEFORE create_app() so overlay path resolution (l4d2host.paths.overlay_path) lands under the temp directory instead of /var/lib/left4me — without this every files-overlay route 404s on macOS (see AGENTS.md "symptom-to-cause"). """ monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) app = _boot_app(tmp_path, monkeypatch) with session_scope() as session: user = User( username="alice", password_digest=hash_password("secret"), admin=False, ) session.add(user) session.flush() # Same insert-then-update-path pattern dev-server.py uses: we # need overlay.id to write into overlay.path, but the column is # NOT NULL so it gets a placeholder until we know the id. overlay = Overlay( name="cfgs", path="_pending", type="files", user_id=user.id, ) session.add(overlay) session.flush() overlay.path = str(overlay.id) user_id = user.id overlay_id = overlay.id overlay_root = tmp_path / "overlays" / str(overlay_id) overlay_root.mkdir(parents=True) (overlay_root / "server.cfg").write_text('hostname "left4me"\n') # 8-byte PNG signature + 60 null bytes — large enough for the # editor's binary-mode detection (which checks the magic header). (overlay_root / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 60) (overlay_root / "cfg").mkdir() (overlay_root / "cfg" / "admins.txt").write_text("STEAM_1:0:1\n") base_url, shutdown = _serve(app) try: yield { "base_url": base_url, "user_id": user_id, "overlay_id": overlay_id, "overlay_root": overlay_root, } finally: shutdown() @pytest.fixture(scope="function") def server_with_files(tmp_path, monkeypatch): """live_server + a Server owned by alice with a populated runtime merged directory. Used by the server-detail e2e tests that exercise file rows + download. The /servers/ page lists files from LEFT4ME_ROOT/runtime//merged/ (the kernel-overlayfs view of a running server). On a dev/test box no overlayfs is mounted, so the fixture pre-creates that directory and seeds one plain file — enough for the file-tree partial to render rows. Yields {base_url, user_id, blueprint_id, server_id, merged_root}. """ monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) app = _boot_app(tmp_path, monkeypatch) with session_scope() as session: user = User( username="alice", password_digest=hash_password("secret"), admin=False, ) session.add(user) session.flush() bp = Blueprint( user_id=user.id, name="bp", arguments="[]", config="[]" ) session.add(bp) session.flush() # Server.port has a global UNIQUE constraint, but this is a # fresh per-test SQLite DB so any value works — 27015 is the # L4D2 default, semantically obvious. server = Server( user_id=user.id, blueprint_id=bp.id, name="srv", port=27015, ) session.add(server) session.flush() user_id = user.id blueprint_id = bp.id server_id = server.id merged_root = tmp_path / "runtime" / str(server_id) / "merged" merged_root.mkdir(parents=True) (merged_root / "server.cfg").write_text('hostname "from-merged-runtime"\n') base_url, shutdown = _serve(app) try: yield { "base_url": base_url, "user_id": user_id, "blueprint_id": blueprint_id, "server_id": server_id, "merged_root": merged_root, } finally: shutdown()