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>
103 lines
3.9 KiB
Python
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)
|