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>
165 lines
5.7 KiB
Python
165 lines
5.7 KiB
Python
"""
|
|
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
|