webapp-scaffold/tests/e2e/test_password_setup.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

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()