webapp-scaffold/tests/e2e/proxy.py
Uwe Schuster 5ee6894916 #7: Add tests/e2e/ for the initial password-setup flow (Option A1)
Closes the integration gap that let two prior regressions ship:
  1. oatpp-authkit query-string 401 (v0.3.3 / commit 46971ac)
  2. VITE_BASE blank page (v0.3.6 / commit b1a13b8)

A1 scope: skips the host-provisioning side of new-project.sh (root,
systemd, Apache, Forgejo). Instead clones webapp-template into a tmp
dir, builds with VITE_BASE pinned to /projects/tmp-foo/, boots the
binary on an ephemeral port, fronts it with an in-process
PrefixStrippingProxy that mirrors the production Apache vhost. Tests
then drive the same flow a real user would.

Files:
- tests/e2e/proxy.py — stdlib-only reverse proxy (~100 LOC,
  ThreadingHTTPServer + urllib). Strips the /projects/<name>/ prefix
  and sets X-Forwarded-Prefix exactly like Apache's ProxyPass.
- tests/e2e/conftest.py — webapp_template_src / built_webapp /
  boot_app / proxy / admin_token fixtures. Honours
  WEBAPP_TEMPLATE_DIR + WEBAPP_TEMPLATE_BUILD_DIR env vars so CI can
  point at a pre-built tree to skip the build step.
- tests/e2e/test_password_setup.py — three assertions per #7:
    - /set-password?token=… returns HTML, not JSON 401
    - every <script src>/<link href> resolves through the prefix
    - /api/* still returns JSON 401 (sanity-check negotiation)

No Selenium dependency — the assertions are HTTP-level and reliable
in CI without a Chrome/Geckodriver setup. Selenium can be added later
for actual form-submission coverage if needed.

Test runs are skipped automatically when webapp-template source is
absent, so the suite is safe to drop into any pytest invocation.

Closes #7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:23:30 +02:00

103 lines
3.9 KiB
Python

"""
PrefixStrippingProxy — minimal in-process HTTP reverse proxy for e2e tests.
Mirrors the production Apache vhost contract (see `templates/projects-*.conf`):
GET /projects/<name>/foo → GET /foo with `X-Forwarded-Prefix: /projects/<name>`
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/<prefix>/...` to the backend.
Backend sees the un-prefixed path plus an `X-Forwarded-Prefix` header,
matching what Apache's `ProxyPass /projects/<name>/ 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)