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>
87 lines
3.7 KiB
Python
87 lines
3.7 KiB
Python
"""
|
|
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()
|