#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>
This commit is contained in:
parent
fd451fd452
commit
5ee6894916
4 changed files with 355 additions and 0 deletions
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
165
tests/e2e/conftest.py
Normal file
165
tests/e2e/conftest.py
Normal file
|
|
@ -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 `<build>/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
|
||||
103
tests/e2e/proxy.py
Normal file
103
tests/e2e/proxy.py
Normal file
|
|
@ -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/<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)
|
||||
87
tests/e2e/test_password_setup.py
Normal file
87
tests/e2e/test_password_setup.py
Normal file
|
|
@ -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/<name>/` 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 "<html" in body.lower() or "<!doctype" in body.lower(), body[:200]
|
||||
|
||||
|
||||
def test_assets_resolve_through_prefix(proxy, admin_token):
|
||||
"""Regression for VITE_BASE blank-page bug: every asset URL referenced
|
||||
by the served HTML must resolve under the prefix."""
|
||||
url = f"{proxy['base_url']}/set-password?token={admin_token}"
|
||||
_, body, _ = _http_get(url)
|
||||
|
||||
asset_urls = re.findall(
|
||||
r'<(?:script|link)[^>]*\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()
|
||||
Loading…
Add table
Reference in a new issue