""" Fixture chain for the e2e password-setup test (issue #7, Option A1 — scoped). Skips the host-provisioning side of `new-project.sh` (root, systemd, Apache, Forgejo). Instead clones webapp-template into a tmp dir, builds it with `VITE_BASE` pinned to a synthetic prefix, boots the binary on an ephemeral port, and fronts it with the in-process PrefixStrippingProxy. The result exercises the same contract that broke twice in production: the build pipeline + the reverse-proxy prefix + the set-password flow. Required environment / tooling on the test host: - `WEBAPP_TEMPLATE_DIR` env var → path to a webapp-template source tree (defaults to /home/git/webapp-template). - cmake + make + a C++ toolchain. - node + npm. - A built webapp binary appears at `/webapp`. The build is cached per pytest session in `tmp_path_factory`'s root, so a warm rerun is fast. To skip the build entirely, set `WEBAPP_TEMPLATE_BUILD_DIR=/path/to/prebuilt-build` (must contain `webapp` and `static/dist/index.html` with `/projects/tmp-foo/` baked into asset URLs). """ import os import shutil import socket import subprocess import time from pathlib import Path from typing import Iterator import pytest from .proxy import PrefixStrippingProxy PREFIX = "/projects/tmp-foo" TEMPLATE_DIR_DEFAULT = "/home/git/webapp-template" def _free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] def _wait_port(port: int, timeout: float = 30.0) -> None: deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: with socket.create_connection(("127.0.0.1", port), timeout=0.5): return except OSError: time.sleep(0.1) raise RuntimeError(f"port {port} did not open within {timeout}s") @pytest.fixture(scope="session") def webapp_template_src() -> Path: src = Path(os.environ.get("WEBAPP_TEMPLATE_DIR", TEMPLATE_DIR_DEFAULT)) if not (src / "CMakeLists.txt").exists(): pytest.skip(f"webapp-template source not found at {src}") return src @pytest.fixture(scope="session") def built_webapp(webapp_template_src: Path, tmp_path_factory) -> Path: """Returns a build directory containing `webapp` and a Vite bundle whose asset URLs include the PREFIX. Honours WEBAPP_TEMPLATE_BUILD_DIR to skip the build step entirely (CI provides a pre-built tree).""" pre = os.environ.get("WEBAPP_TEMPLATE_BUILD_DIR") if pre: return Path(pre) work = tmp_path_factory.mktemp("webapp-template-build") # Shallow copy: source files only, skip node_modules + build/ if present. for entry in webapp_template_src.iterdir(): if entry.name in {"build", "node_modules", ".git"}: continue dest = work / entry.name if entry.is_dir(): shutil.copytree(entry, dest, symlinks=True, ignore=shutil.ignore_patterns("node_modules", "build")) else: shutil.copy2(entry, dest) # Pin VITE_BASE for the production build (matches what new-project.sh # would write into a deployed project). (work / "frontend").mkdir(exist_ok=True) (work / "frontend" / ".env.production").write_text(f"VITE_BASE={PREFIX}/\n") build_dir = work / "build" build_dir.mkdir(exist_ok=True) env = {**os.environ, "VITE_BASE": f"{PREFIX}/"} subprocess.run( ["cmake", "-DCMAKE_BUILD_TYPE=Release", ".."], cwd=build_dir, env=env, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, ) subprocess.run( ["make", "-j2"], cwd=build_dir, env=env, check=True, ) return build_dir @pytest.fixture def boot_app(built_webapp: Path, tmp_path: Path) -> Iterator[dict]: """Spawns the built webapp binary on an ephemeral port. Returns {port, data_dir, db_path, proc}. Tears down on test exit.""" binary = built_webapp / "webapp" if not binary.exists(): pytest.skip(f"webapp binary not at {binary}") port = _free_port() data_dir = tmp_path / "data" data_dir.mkdir() db_path = tmp_path / "app.sqlite" log = open(tmp_path / "webapp.log", "wb") env = {**os.environ, "FEWO_ENCRYPTION_KEY": "x" * 64} proc = subprocess.Popen( [str(binary), "--data-dir", str(built_webapp / ".." / "static"), # served from source tree "--db", str(db_path), "--port", str(port)], env=env, stdout=log, stderr=subprocess.STDOUT, ) try: _wait_port(port) yield {"port": port, "data_dir": data_dir, "db_path": db_path, "proc": proc, "binary": binary} finally: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() log.close() @pytest.fixture def proxy(boot_app: dict) -> Iterator[dict]: """Spawns the prefix-stripping proxy in front of the booted app.""" p = PrefixStrippingProxy("127.0.0.1", boot_app["port"], PREFIX) p.start() try: yield {"port": p.port, "prefix": PREFIX, "base_url": f"http://127.0.0.1:{p.port}{PREFIX}"} finally: p.stop() @pytest.fixture def admin_token(boot_app: dict) -> str: """Issues a one-shot password-setup token via the binary's CLI mode.""" out = subprocess.run( [str(boot_app["binary"]), "--db", str(boot_app["db_path"]), "--issue-admin-reset", "tester"], capture_output=True, text=True, check=True, ) # `--issue-admin-reset` prints the raw token on stdout (last line). token = out.stdout.strip().splitlines()[-1].strip() if not token or len(token) < 16: raise RuntimeError(f"unexpected --issue-admin-reset output: {out.stdout!r}") return token