diff --git a/l4d2web/tests/e2e/conftest.py b/l4d2web/tests/e2e/conftest.py index 491efac..fb11bf4 100644 --- a/l4d2web/tests/e2e/conftest.py +++ b/l4d2web/tests/e2e/conftest.py @@ -14,7 +14,7 @@ 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, User +from l4d2web.models import Blueprint, Overlay, User def _free_port() -> int: @@ -25,17 +25,19 @@ def _free_port() -> int: return port -@pytest.fixture(scope="function") -def live_server(tmp_path, monkeypatch): +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) - # app.py:57 sets SESSION_COOKIE_SECURE = not TESTING, which would - # mark the session cookie Secure. The browser then drops it over - # http://127.0.0.1 in e2e tests and the login flow silently fails - # with a redirect back to /login. Force it off explicitly via the - # env-var override (app.py:53-55) rather than flipping TESTING, - # which would skip the SECRET_KEY guard and other production paths. 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 @@ -45,6 +47,37 @@ def live_server(tmp_path, monkeypatch): # 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): + app = _boot_app(tmp_path, monkeypatch) with session_scope() as session: user = User( @@ -62,16 +95,72 @@ def live_server(tmp_path, monkeypatch): blueprint_id = bp.id user_id = user.id - port = _free_port() - server = make_server("127.0.0.1", port, app) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() + base_url, shutdown = _serve(app) try: yield { - "base_url": f"http://127.0.0.1:{port}", + "base_url": base_url, "user_id": user_id, "blueprint_id": blueprint_id, } finally: - server.shutdown() - thread.join(timeout=2) + 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()