diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..cb9f0c5 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,165 @@ +""" +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 diff --git a/tests/e2e/proxy.py b/tests/e2e/proxy.py new file mode 100644 index 0000000..8501cea --- /dev/null +++ b/tests/e2e/proxy.py @@ -0,0 +1,103 @@ +""" +PrefixStrippingProxy — minimal in-process HTTP reverse proxy for e2e tests. + +Mirrors the production Apache vhost contract (see `templates/projects-*.conf`): + + GET /projects//foo → GET /foo with `X-Forwarded-Prefix: /projects/` + +Used by the e2e password-setup test to exercise the same path-rewriting +flow that broke twice in production (oatpp-authkit query-string 401, and +the VITE_BASE blank-page bug). Plain stdlib only — no third-party deps. +""" + +import threading +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Optional + +_HOP_BY_HOP = { + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", "content-length", "host", +} + + +class PrefixStrippingProxy: + """Forwards every request from `:proxy_port//...` to the backend. + + Backend sees the un-prefixed path plus an `X-Forwarded-Prefix` header, + matching what Apache's `ProxyPass /projects// http://127.0.0.1:N/` + does in production. + """ + + def __init__(self, target_host: str, target_port: int, prefix: str): + self.target = f"http://{target_host}:{target_port}" + self.prefix = "/" + prefix.strip("/") + self.server: Optional[ThreadingHTTPServer] = None + self.thread: Optional[threading.Thread] = None + self.port: Optional[int] = None + + def start(self) -> int: + proxy = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): proxy._forward(self) + def do_POST(self): proxy._forward(self) + def do_PUT(self): proxy._forward(self) + def do_DELETE(self): proxy._forward(self) + def do_PATCH(self): proxy._forward(self) + def log_message(self, *a, **kw): pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.port = self.server.server_address[1] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + return self.port + + def stop(self) -> None: + if self.server: + self.server.shutdown() + self.server.server_close() + + def _strip_prefix(self, path: str) -> str: + if path.startswith(self.prefix + "/"): + return path[len(self.prefix):] + if path == self.prefix: + return "/" + return path + + def _forward(self, h: BaseHTTPRequestHandler) -> None: + upstream_path = self._strip_prefix(h.path) + body_len = int(h.headers.get("Content-Length") or 0) + body = h.rfile.read(body_len) if body_len else None + req = urllib.request.Request(self.target + upstream_path, method=h.command, data=body) + for k, v in h.headers.items(): + if k.lower() in _HOP_BY_HOP: + continue + req.add_header(k, v) + req.add_header("X-Forwarded-Prefix", self.prefix) + req.add_header("X-Forwarded-Host", h.headers.get("Host", "127.0.0.1")) + req.add_header("X-Forwarded-Proto", "http") + + try: + r = urllib.request.urlopen(req, timeout=15) + status, headers, content = r.status, r.headers, r.read() + except urllib.error.HTTPError as e: + status, headers, content = e.code, e.headers, e.read() + except Exception as exc: + h.send_response(502) + msg = f"proxy upstream error: {exc}".encode() + h.send_header("Content-Type", "text/plain; charset=utf-8") + h.send_header("Content-Length", str(len(msg))) + h.end_headers() + h.wfile.write(msg) + return + + h.send_response(status) + for k, v in headers.items(): + if k.lower() in _HOP_BY_HOP: + continue + h.send_header(k, v) + h.send_header("Content-Length", str(len(content))) + h.end_headers() + h.wfile.write(content) diff --git a/tests/e2e/test_password_setup.py b/tests/e2e/test_password_setup.py new file mode 100644 index 0000000..9620a69 --- /dev/null +++ b/tests/e2e/test_password_setup.py @@ -0,0 +1,87 @@ +""" +End-to-end test for the initial password-setup flow on a scaffolded project +(webapp-scaffold #7, Option A1). + +Reproduces the contract that broke twice in production: + 1. oatpp-authkit AuthInterceptor rejected `/set-password?token=…` with 401 + because the public-path check compared against the request-target + (which includes the query string). v0.3.3 / commit 46971ac. + 2. Newly-scaffolded projects shipped with `VITE_BASE='/'` so SPA assets + 404'd behind the `/projects//` Apache prefix → blank page. + webapp-scaffold v0.3.6 / commit b1a13b8. + +Both regressions slipped past the existing unit/component test layers +because none of them exercises the *deployed* shape of a scaffolded +project. The fixtures in `conftest.py` recreate that shape inline: +clone webapp-template, build with VITE_BASE pinned, boot the binary, +front it with the in-process PrefixStrippingProxy, follow the email +link. +""" + +import re +import urllib.request +import urllib.error + +import pytest + + +def _http_get(url: str, *, headers=None) -> tuple[int, str, dict]: + req = urllib.request.Request(url, headers=headers or {"Accept": "text/html"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return r.status, r.read().decode("utf-8", "replace"), dict(r.headers) + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace"), dict(e.headers) + + +def test_set_password_link_returns_html(proxy, admin_token): + """Regression for oatpp-authkit query-string 401: the link must serve + the SPA HTML, not a JSON 401.""" + url = f"{proxy['base_url']}/set-password?token={admin_token}" + status, body, headers = _http_get(url) + assert status == 200, f"got {status}: {body[:200]}" + assert "text/html" in headers.get("Content-Type", "").lower(), headers + # The SPA shell loads the bundle; the form itself is rendered by JS, + # so we just assert the shell is well-formed. + assert "]*\b(?:src|href)\s*=\s*["\']([^"\']+)["\']', + body, re.IGNORECASE, + ) + # Only check absolute-path asset URLs (skip cross-origin, data:, etc.). + local = [u for u in asset_urls if u.startswith("/")] + assert local, f"no local asset URLs found in HTML: {body[:300]}" + + failures = [] + for path in local: + # The asset URL is browser-side — already includes the proxy prefix. + # Fetch it through the proxy so we exercise the same path the + # browser would. + full = f"http://127.0.0.1:{proxy['port']}{path}" + s, _, _ = _http_get(full) + if s != 200: + failures.append(f"{path} → {s}") + + assert not failures, "asset URLs failed to resolve through the prefix:\n " \ + + "\n ".join(failures) + + +def test_api_path_still_returns_json_401(proxy): + """Sanity check the content-negotiation: an /api/ call without a token + must still get JSON 401 (browser navigation gets HTML, but API stays JSON).""" + full = f"http://127.0.0.1:{proxy['port']}{proxy['prefix']}/api/users" + req = urllib.request.Request(full, headers={"Accept": "application/json"}) + try: + urllib.request.urlopen(req, timeout=5) + pytest.fail("expected 401 from unauth /api/ call") + except urllib.error.HTTPError as e: + assert e.code == 401 + assert "application/json" in e.headers.get("Content-Type", "").lower()