diff --git a/build.zig.zon b/build.zig.zon index 962b38f2..b2d574e4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -4,28 +4,19 @@ .fingerprint = 0x306d84a90c011454, .minimum_zig_version = "0.16.0", .dependencies = .{ - // FORK, not upstream — `valthon/zap` at upstream v0.10.6 (f6099ece) plus ONE - // commit: a two-line fix to the bundled facil.io (issue #380). - // - // `fio_poll`'s review loop force-closes on any error event, deriving the uuid - // from the raw descriptor in the event via `fd2uuid(fd)`. A poll/epoll result - // set is a SNAPSHOT: by the time the loop reaches an entry, another thread may - // have closed that descriptor and the OS may have reissued the number to an - // unrelated socket opened elsewhere in the process. `fd2uuid` re-reads the - // descriptor's CURRENT counter, so `uuid_is_valid()` can never reject a stale - // event — facil.io then closes a descriptor it does not own. Surfaced here as - // an intermittent EBADF panic in a queue worker whose SMTP socket was closed - // between `connect()` and `getsockname()`; it can take out ANY descriptor, - // including SQLite's. The fix gates the close on `.open`, the flag facil.io's - // own `fio_is_closed()` already consults. - // - // Upstream (including v0.11.0) still carries the unguarded call, so bumping - // the pin does NOT fix this. When re-pinning zap, rebase this commit onto the - // new upstream tag rather than dropping back to a plain zigzap/zap url — and - // keep `zap_version` / `facil_version` in build.zig in sync with the base tag. + // Fork of upstream v0.10.6 (f6099ece), retaining issue #380's protection + // for descriptors outside facil.io and the connection-generation fix: + // https://github.com/valthon/zap/pull/1 + // Poll snapshots and deferred callbacks retain the registered connection + // identity. Validation under the connection lock prevents an old event + // from closing, flushing or invoking callbacks on a recycled descriptor. + // Flush and close revalidate identity inside their respective locks. This fixes + // connection resets and truncated HTTP responses during browser startup. + // Preserve both fixes when upgrading; keep build.zig's upstream versions + // in sync with the fork's base tag. .zap = .{ - .url = "git+https://github.com/valthon/zap#1d7cb3b8cf288f9f78d767d19159b491d208a051", - .hash = "zap-0.10.6-GoeB80SLJACZeWlVU-LnGsweCgPJdMlYVkRsWNoOlzvi", + .url = "git+https://github.com/valthon/zap#2bfc918ff1e07ced69fb5535c19b56251c9fcea0", + .hash = "zap-0.10.6-GoeB8_TjJABuc_dEhxCk4cDo7Bwu3-3-3BeY6b5jaaVg", }, }, .paths = .{ diff --git a/changelog.d/http-connection-generation.md b/changelog.d/http-connection-generation.md new file mode 100644 index 00000000..fd82ee0f --- /dev/null +++ b/changelog.d/http-connection-generation.md @@ -0,0 +1,3 @@ +### Fixes + +- Prevent stale HTTP connection events from closing a replacement connection after the operating system reuses its descriptor, avoiding connection resets and truncated pages during browser navigation. diff --git a/tests/admin/conftest.py b/tests/admin/conftest.py index e2343d04..41d2b8a2 100644 --- a/tests/admin/conftest.py +++ b/tests/admin/conftest.py @@ -1,5 +1,6 @@ import os, re, socket, subprocess, tempfile, time, shutil, pathlib, pytest import json, urllib.request, urllib.error +from contextlib import ExitStack, contextmanager from _bin import resolve_binary REPO = pathlib.Path(__file__).resolve().parents[2] @@ -29,8 +30,41 @@ # so an inherited ZIGBASE_SERVE_BACKGROUND=1 would be a mistake to honor. os.environ["ZIGBASE_SERVE_BACKGROUND"] = "0" -def _free_port(): - s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p +@contextmanager +def _reserve_port(): + """Hold a non-listening loopback reservation while a REUSEADDR HTTP child binds. + + An explicit context owns the socket through successful startup; fixtures + may retain it until server cleanup. Other workers' port-zero allocations + cannot select it during the spawn/bind gap. + facil.io sets SO_REUSEADDR too, so its listener can bind while this socket + remains open. This helper is for ZigBase HTTP servers, not SMTP/mock servers. + """ + with socket.socket() as reservation: + reservation.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + reservation.bind(("127.0.0.1", 0)) + yield reservation.getsockname()[1] + +def _server_diagnostics(node, proc, log_path): + try: + output = pathlib.Path(log_path).read_text(errors="replace") + except OSError as error: + output = f"" + return f"\nServer diagnostics for {node.nodeid}: pid={proc.pid}, exit_before_cleanup={proc.poll()!r}\n{log_path}:\n{output}" + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + report = outcome.get_result() + setattr(item, "rep_" + report.when, report) + if report.when == "setup" and report.failed: + context = getattr(item, "_server_diagnostic_context", None) + if context is not None: + # A passing teardown's captured output is hidden after setup errors, + # including under xdist. Attach live evidence before report delivery. + report.sections.append(("Server diagnostics", _server_diagnostics(item, *context))) + @pytest.fixture(scope="session") def binary(): @@ -66,33 +100,45 @@ def _cleanup_su_templates(): @pytest.fixture() def server(binary, request): - data = tempfile.mkdtemp(prefix="zb_admin_") - shutil.copytree(_su_template_for(binary), data, dirs_exist_ok=True) - port = _free_port() - extra_env = getattr(request, "param", None) or {} - env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_HTTP_PORT": str(port), - # The harness owns this process as a FOREGROUND child (terminate() in the - # finally). Without the pin, running the suite from inside an AI-agent - # session (CLAUDECODE etc.) would trigger serve's auto-backgrounding: the - # parent exits after readiness, terminate() hits the dead parent, and the - # detached child leaks past the test. - "ZIGBASE_SERVE_BACKGROUND": "0", **extra_env} - # Plain-HTTP local test server: opt out of Secure cookies (default-on) so the - # browser stores the auth/CSRF cookies over http://; the default loopback bind - # and auto-generated JWT secret are exactly what we want. - log_path = os.path.join(data, "server.log") - with open(log_path, "wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, - stdout=log, stderr=subprocess.STDOUT) - base = f"http://127.0.0.1:{port}" - try: - _wait_reachable_or_fail(proc, port, log_path) - yield base - finally: + with tempfile.TemporaryDirectory(prefix="zb_admin_") as data, _reserve_port() as port: + shutil.copytree(_su_template_for(binary), data, dirs_exist_ok=True) + extra_env = getattr(request, "param", None) or {} + env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_HTTP_PORT": str(port), + # The harness owns this process as a FOREGROUND child (terminate() in the + # finally). Without the pin, running the suite from inside an AI-agent + # session (CLAUDECODE etc.) would trigger serve's auto-backgrounding: the + # parent exits after readiness, terminate() hits the dead parent, and the + # detached child leaks past the test. + "ZIGBASE_SERVE_BACKGROUND": "0", **extra_env} + # Plain-HTTP local test server: opt out of Secure cookies (default-on) so the + # browser stores the auth/CSRF cookies over http://; the default loopback bind + # and auto-generated JWT secret are exactly what we want. + log_path = os.path.join(data, "server.log") + with open(log_path, "wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, + stdout=log, stderr=subprocess.STDOUT) + base = f"http://127.0.0.1:{port}" + ready = False try: - _stop_server(proc) + _wait_reachable_or_fail(proc, port, log_path) + ready = True + request.node._server_diagnostic_context = (proc, log_path) + yield base finally: - shutil.rmtree(data, ignore_errors=True) + try: + exited = proc.poll() + failed = any( + getattr(getattr(request.node, "rep_" + phase, None), "failed", False) + for phase in ("setup", "call") + ) + if exited is not None or failed: + diagnostics = _server_diagnostics(request.node, proc, log_path) + if ready and exited is not None and not failed: + pytest.fail("Server exited unexpectedly before fixture cleanup." + diagnostics, pytrace=False) + print(diagnostics) + finally: + request.node.__dict__.pop("_server_diagnostic_context", None) + _stop_server(proc) # Launching Chromium (~0.5s) once per test dominated the wall time of the (now # parallel) suite. Reuse ONE browser process per test session — under pytest-xdist @@ -148,24 +194,43 @@ def _stop_server(proc): proc.wait(timeout=5) -def _wait_reachable_or_fail(proc, port, log_path, timeout_s=5.0): +def _wait_reachable_or_fail(proc, port, log_path, timeout_s=5.0, *, log_start=0): """Require HTTP health readiness, not merely an open socket; raise instead of returning silently if the server dies early or never comes up. A fixture that raises here fails cleanly at setup, instead of yielding a base URL nothing is listening on and leaving every test in the fixture to fail later with a generic connection error.""" deadline = time.monotonic() + timeout_s opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - while time.monotonic() < deadline: - if proc.poll() is not None: - break # process already exited; no point polling further - try: - with opener.open(f"http://127.0.0.1:{port}/api/health", timeout=0.2) as response: - body = json.load(response) - if response.status == 200 and isinstance(body, dict) and body.get("status") == "ok": - return - except (OSError, ValueError, urllib.error.URLError): - pass - time.sleep(0.1) + output = "" + reader = None + with ExitStack() as logs: + while time.monotonic() < deadline: + if proc.poll() is not None: + break # process already exited; no point polling further + if reader is None: + try: + reader = logs.enter_context(pathlib.Path(log_path).open("rb")) + reader.seek(log_start) + except FileNotFoundError: + pass + if reader is not None: + output += reader.read().decode(errors="replace") + if re.search(r"cannot bind [^\n]*:" + str(port) + r": ListenError", output): + break # This child lost the bind; a foreign healthy listener is irrelevant. + # Bind success belongs to the spawned PID, not merely this port. The PID + # also distinguishes restarts whose logs append to an earlier launch. + listening = (re.search(r'zigbase listening on http://[^"\\\s]+:' + str(port) + r'\b', output) or + re.search(r"Listening on port " + str(port) + r"\b", output)) + own_process = re.search(r"\* Root pid: " + str(proc.pid) + r"(?:\s|$)", output) + if listening and own_process: + try: + with opener.open(f"http://127.0.0.1:{port}/api/health", timeout=0.2) as response: + body = json.load(response) + if response.status == 200 and isinstance(body, dict) and body.get("status") == "ok" and proc.poll() is None: + return + except (OSError, ValueError, urllib.error.URLError): + pass + time.sleep(0.1) _stop_server(proc) output = pathlib.Path(log_path).read_text(errors="replace") if pathlib.Path(log_path).exists() else "" raise AssertionError(f"server on port {port} never became reachable (exit={proc.returncode}):\n{output}") @@ -173,28 +238,20 @@ def _wait_reachable_or_fail(proc, port, log_path, timeout_s=5.0): @pytest.fixture() def auth2_server(auth2_binary): """A live table-mode server with the beforeAuthSuccess fixture hook registered.""" - data = tempfile.mkdtemp(prefix="zb_auth2_") - shutil.copytree(_su_template_for(auth2_binary), data, dirs_exist_ok=True) - port = _free_port() - # Foreground pin: see the server fixture's comment on ZIGBASE_SERVE_BACKGROUND. - env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_HTTP_PORT": str(port), - "ZIGBASE_SERVE_BACKGROUND": "0"} - log_path = os.path.join(data, "server.log") - with open(log_path, "wb") as log: - proc = subprocess.Popen([auth2_binary, "serve", "--insecure-cookies"], env=env, - stdout=log, stderr=subprocess.STDOUT) - try: - _wait_reachable_or_fail(proc, port, log_path) - except AssertionError: - shutil.rmtree(data, ignore_errors=True) - raise - try: - yield f"http://127.0.0.1:{port}" - finally: + with tempfile.TemporaryDirectory(prefix="zb_auth2_") as data, _reserve_port() as port: + shutil.copytree(_su_template_for(auth2_binary), data, dirs_exist_ok=True) + # Foreground pin: see the server fixture's comment on ZIGBASE_SERVE_BACKGROUND. + env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_HTTP_PORT": str(port), + "ZIGBASE_SERVE_BACKGROUND": "0"} + log_path = os.path.join(data, "server.log") + with open(log_path, "wb") as log: + proc = subprocess.Popen([auth2_binary, "serve", "--insecure-cookies"], env=env, + stdout=log, stderr=subprocess.STDOUT) try: - _stop_server(proc) + _wait_reachable_or_fail(proc, port, log_path) + yield f"http://127.0.0.1:{port}" finally: - shutil.rmtree(data, ignore_errors=True) + _stop_server(proc) @pytest.fixture() def auth2_page(_browser, auth2_server): diff --git a/tests/admin/test_agent_contracts.py b/tests/admin/test_agent_contracts.py index 4b4c1f7d..ea20510a 100644 --- a/tests/admin/test_agent_contracts.py +++ b/tests/admin/test_agent_contracts.py @@ -7,14 +7,13 @@ fixture sends stdout/stderr to `DEVNULL` (conftest.py:52) and this test needs to read the log stream. """ +from conftest import _reserve_port import json, os, pathlib, shutil, socket, sqlite3, subprocess, tempfile, time, urllib.request, urllib.error import pytest REPO = pathlib.Path(__file__).resolve().parents[2] -def _free_port(): - s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p class LoggedServer: @@ -40,33 +39,33 @@ def get(self, path): def logged_server(binary, request): flags = list(getattr(request, "param", []) or []) data = tempfile.mkdtemp(prefix="zb_logged_") - port = _free_port() - log_path = pathlib.Path(data) / "server.log" - # Foreground pin: this fixture asserts on the log file its own redirect - # captures, so serve must NOT auto-background itself when the suite runs - # inside an AI-agent session (CLAUDECODE etc.) — backgrounding would route - # the request log to /serve.log and leak the detached child past - # terminate(). See tests/admin/conftest.py's server fixture for the same pin. - env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_HTTP_PORT": str(port), - "ZIGBASE_SERVE_BACKGROUND": "0"} - with open(log_path, "w") as log: - proc = subprocess.Popen( - [binary, "serve", "--insecure-cookies", *flags], - env=env, stdout=log, stderr=subprocess.STDOUT, - ) - for _ in range(50): + with _reserve_port() as port: + log_path = pathlib.Path(data) / "server.log" + # Foreground pin: this fixture asserts on the log file its own redirect + # captures, so serve must NOT auto-background itself when the suite runs + # inside an AI-agent session (CLAUDECODE etc.) — backgrounding would route + # the request log to /serve.log and leak the detached child past + # terminate(). See tests/admin/conftest.py's server fixture for the same pin. + env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_HTTP_PORT": str(port), + "ZIGBASE_SERVE_BACKGROUND": "0"} + with open(log_path, "w") as log: + proc = subprocess.Popen( + [binary, "serve", "--insecure-cookies", *flags], + env=env, stdout=log, stderr=subprocess.STDOUT, + ) + for _ in range(50): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + time.sleep(0.1) + else: + proc.terminate() + pytest.fail(f"server never became reachable; log:\n{log_path.read_text()}") try: - with socket.create_connection(("127.0.0.1", port), timeout=0.2): - break - except OSError: - time.sleep(0.1) - else: - proc.terminate() - pytest.fail(f"server never became reachable; log:\n{log_path.read_text()}") - try: - yield LoggedServer(f"http://127.0.0.1:{port}", proc, log_path, data) - finally: - proc.terminate(); proc.wait(timeout=5); shutil.rmtree(data, ignore_errors=True) + yield LoggedServer(f"http://127.0.0.1:{port}", proc, log_path, data) + finally: + proc.terminate(); proc.wait(timeout=5); shutil.rmtree(data, ignore_errors=True) @pytest.mark.parametrize("logged_server", [["--log-format", "json"]], indirect=True) diff --git a/tests/admin/test_custom_route.py b/tests/admin/test_custom_route.py index eaf3ba67..0013f4e3 100644 --- a/tests/admin/test_custom_route.py +++ b/tests/admin/test_custom_route.py @@ -1,37 +1,36 @@ +from conftest import _reserve_port import json, socket, subprocess, time, urllib.request, os, signal, tempfile, pathlib from _bin import resolve_binary REPO = pathlib.Path(__file__).resolve().parents[2] BLOG = REPO / "examples" / "blog" -def _free_port(): - s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p def test_custom_public_route_responds(): # Build the example consumer against the working-tree zigbase module. blog = resolve_binary("ZIGBASE_TEST_BLOG_BINARY", BLOG, "blog") with tempfile.TemporaryDirectory() as data: - port = _free_port() - proc = subprocess.Popen( - [str(blog), "serve", "--http-port", str(port), "--data-dir", data], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - url = f"http://127.0.0.1:{port}/api/blog/ping" - deadline = time.time() + 20 - status, body = None, None - while time.time() < deadline: - try: - with urllib.request.urlopen(url, timeout=1) as r: - status = r.status; body = r.read().decode(); break - except Exception: - time.sleep(0.2) - assert body is not None, "server did not come up / route did not respond" - assert status == 200, f"status={status} body={body}" - assert json.loads(body) == {"pong": True}, f"unexpected body: {body}" - finally: - try: proc.send_signal(signal.SIGINT) - except Exception: pass - try: proc.terminate() - except Exception: pass - proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(blog), "serve", "--http-port", str(port), "--data-dir", data], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + url = f"http://127.0.0.1:{port}/api/blog/ping" + deadline = time.time() + 20 + status, body = None, None + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as r: + status = r.status; body = r.read().decode(); break + except Exception: + time.sleep(0.2) + assert body is not None, "server did not come up / route did not respond" + assert status == 200, f"status={status} body={body}" + assert json.loads(body) == {"pong": True}, f"unexpected body: {body}" + finally: + try: proc.send_signal(signal.SIGINT) + except Exception: pass + try: proc.terminate() + except Exception: pass + proc.wait(timeout=10) diff --git a/tests/admin/test_durable_realtime.py b/tests/admin/test_durable_realtime.py index 9e7db049..e6762ea0 100644 --- a/tests/admin/test_durable_realtime.py +++ b/tests/admin/test_durable_realtime.py @@ -10,7 +10,7 @@ import pytest -from conftest import _free_port, _stop_server, _su_template_for, _wait_reachable_or_fail +from conftest import _reserve_port, _stop_server, _su_template_for, _wait_reachable_or_fail from test_realtime_backfill import backfill, call, setup from test_realtime_backfill import ( test_checkpoint_before_snapshot_and_id_only_updates_and_delete, @@ -29,18 +29,18 @@ def binary(): @contextlib.contextmanager def running(binary, data, extra_env=None): - port = _free_port() - env = {**os.environ, "ZIGBASE_DATA_DIR": str(data), "ZIGBASE_HTTP_PORT": str(port), - "ZIGBASE_SERVE_BACKGROUND": "0", **(extra_env or {})} - log_path = data / "server.log" - with log_path.open("wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, - stdout=log, stderr=subprocess.STDOUT) - try: - _wait_reachable_or_fail(proc, port, str(log_path)) - yield f"http://127.0.0.1:{port}" - finally: - _stop_server(proc) + with _reserve_port() as port: + env = {**os.environ, "ZIGBASE_DATA_DIR": str(data), "ZIGBASE_HTTP_PORT": str(port), + "ZIGBASE_SERVE_BACKGROUND": "0", **(extra_env or {})} + log_path = data / "server.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, + stdout=log, stderr=subprocess.STDOUT) + try: + _wait_reachable_or_fail(proc, port, str(log_path)) + yield f"http://127.0.0.1:{port}" + finally: + _stop_server(proc) def test_restart_retains_cursor_and_authorization(binary, tmp_path): diff --git a/tests/admin/test_durable_uploads.py b/tests/admin/test_durable_uploads.py index 164e536c..14617eca 100644 --- a/tests/admin/test_durable_uploads.py +++ b/tests/admin/test_durable_uploads.py @@ -8,7 +8,7 @@ import urllib.error import pytest -from conftest import _free_port, _su_template_for, _wait_reachable_or_fail +from conftest import _reserve_port, _su_template_for, _wait_reachable_or_fail import test_resumable_uploads as network @@ -25,34 +25,35 @@ def binary(): def running(binary, tmp_path): data = tmp_path / "data" shutil.copytree(_su_template_for(binary), data) - port = _free_port() - base = f"http://127.0.0.1:{port}" - env = {**os.environ, "ZIGBASE_DATA_DIR": str(data), "ZIGBASE_HTTP_PORT": str(port), - "ZIGBASE_SERVE_BACKGROUND": "0"} - log_path = tmp_path / "server.log" - processes = [] - - def start(*, ready=True, other_port=None): - launch_env = {**env, "ZIGBASE_HTTP_PORT": str(other_port or port)} - args = [binary, "serve", "--insecure-cookies"] - if other_port: - args.append("--ignore-lock") # Exercise upload ownership, not CLI tracking. - with log_path.open("ab") as log: - proc = subprocess.Popen(args, env=launch_env, - stdout=log, stderr=subprocess.STDOUT) - processes.append(proc) - if ready: - _wait_reachable_or_fail(proc, other_port or port, str(log_path)) - return proc - - proc = start() - try: - yield base, data, proc, start, log_path - finally: - for child in processes: - if child.poll() is None: - child.kill() - child.wait(timeout=10) + with _reserve_port() as port: + base = f"http://127.0.0.1:{port}" + env = {**os.environ, "ZIGBASE_DATA_DIR": str(data), "ZIGBASE_HTTP_PORT": str(port), + "ZIGBASE_SERVE_BACKGROUND": "0"} + log_path = tmp_path / "server.log" + processes = [] + + def start(*, ready=True, other_port=None): + launch_env = {**env, "ZIGBASE_HTTP_PORT": str(other_port or port)} + args = [binary, "serve", "--insecure-cookies"] + if other_port: + args.append("--ignore-lock") # Exercise upload ownership, not CLI tracking. + log_start = log_path.stat().st_size if log_path.exists() else 0 + with log_path.open("ab") as log: + proc = subprocess.Popen(args, env=launch_env, + stdout=log, stderr=subprocess.STDOUT) + processes.append(proc) + if ready: + _wait_reachable_or_fail(proc, other_port or port, str(log_path), log_start=log_start) + return proc + + proc = start() + try: + yield base, data, proc, start, log_path + finally: + for child in processes: + if child.poll() is None: + child.kill() + child.wait(timeout=10) def kill(proc): @@ -275,13 +276,14 @@ def test_owner_lock_and_persisted_principal_quota(running): _, tokens, _, record = network.setup(base) assert network.begin(base, tokens[0], record, 64)[0] == 201 assert network.begin(base, tokens[0], record, 64)[0] == 201 - competing = start(ready=False, other_port=_free_port()) - assert competing.wait(timeout=10) != 0 - assert "WouldBlock" in log_path.read_text() - kill(proc) - start() - assert network.begin(base, tokens[0], record)[0] == 429 - assert network.begin(base, tokens[1], record)[0] == 429 + with _reserve_port() as other_port: + competing = start(ready=False, other_port=other_port) + assert competing.wait(timeout=10) != 0 + assert "WouldBlock" in log_path.read_text() + kill(proc) + start() + assert network.begin(base, tokens[0], record)[0] == 429 + assert network.begin(base, tokens[1], record)[0] == 429 def test_competing_owner_cannot_run_startup_cleanup(running): @@ -289,15 +291,16 @@ def test_competing_owner_cannot_run_startup_cleanup(running): with sqlite3.connect(data / "data.db") as conn: conn.execute("INSERT INTO _authChallenges VALUES(?,?,?,?,?,?,?,?)", ("startup-marker", "users", "probe", "marker", "{}", 0, 0, "2000-01-01")) - competing = start(ready=False, other_port=_free_port()) - assert competing.wait(timeout=10) != 0 - assert "WouldBlock" in log_path.read_text() - with sqlite3.connect(data / "data.db") as conn: - assert conn.execute("SELECT id FROM _authChallenges WHERE id='startup-marker'").fetchone() == ("startup-marker",) - kill(proc) - start() - with sqlite3.connect(data / "data.db") as conn: - assert conn.execute("SELECT id FROM _authChallenges WHERE id='startup-marker'").fetchone() is None + with _reserve_port() as other_port: + competing = start(ready=False, other_port=other_port) + assert competing.wait(timeout=10) != 0 + assert "WouldBlock" in log_path.read_text() + with sqlite3.connect(data / "data.db") as conn: + assert conn.execute("SELECT id FROM _authChallenges WHERE id='startup-marker'").fetchone() == ("startup-marker",) + kill(proc) + start() + with sqlite3.connect(data / "data.db") as conn: + assert conn.execute("SELECT id FROM _authChallenges WHERE id='startup-marker'").fetchone() is None @pytest.mark.parametrize("mutation,expected", [ diff --git a/tests/admin/test_file_range.py b/tests/admin/test_file_range.py index 86693270..6ff1708b 100644 --- a/tests/admin/test_file_range.py +++ b/tests/admin/test_file_range.py @@ -1,3 +1,4 @@ +from conftest import _reserve_port import io, json, socket, subprocess, tempfile, time, os, pathlib, shutil, urllib.request, urllib.error, uuid import pytest @@ -6,8 +7,6 @@ REPO = pathlib.Path(__file__).resolve().parents[2] -def _free_port(): - s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p def _wait_up(url, deadline_s=20): @@ -65,16 +64,16 @@ def file_server(): data = tempfile.mkdtemp(prefix="zb_files_") subprocess.run([str(binary), "superuser", "create", "--email", "admin@x.io", "--password", "adminpassword", "--data-dir", data], check=True) - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--insecure-cookies", "--http-port", str(port), "--data-dir", data], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - try: - yield base - finally: - proc.terminate(); proc.wait(timeout=5); shutil.rmtree(data, ignore_errors=True) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--insecure-cookies", "--http-port", str(port), "--data-dir", data], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + try: + yield base + finally: + proc.terminate(); proc.wait(timeout=5); shutil.rmtree(data, ignore_errors=True) def _setup_record(base, blob, view_rule="@public"): diff --git a/tests/admin/test_import.py b/tests/admin/test_import.py index 6f296738..b7c77cd4 100644 --- a/tests/admin/test_import.py +++ b/tests/admin/test_import.py @@ -10,6 +10,7 @@ The `import-fixture` binary (fixtures/import/main.zig) declares an auth collection `members` (password) and a base collection `vault` with an `.encrypted` field `secret`. """ +from conftest import _reserve_port import json import os import pathlib @@ -27,12 +28,6 @@ FIELD_KEY = "an-operator-supplied-field-key-32b" # >= 32 bytes -def _free_port(): - s = socket.socket() - s.bind(("127.0.0.1", 0)) - p = s.getsockname()[1] - s.close() - return p @pytest.fixture(scope="session") @@ -82,30 +77,30 @@ def _serve(binary, data): or never binds within the deadline, terminate/kill it and pytest.fail with its captured stderr, so a boot failure surfaces here instead of as opaque connection-refused later. """ - port = _free_port() - log_path = os.path.join(data, "serve.log") - with open(log_path, "wb") as log: - proc = subprocess.Popen( - [binary, "serve", "--insecure-cookies", "--http-port", str(port)], - env=_env(data), stdout=log, stderr=subprocess.STDOUT, - ) - for _ in range(50): - if proc.poll() is not None: - break # process already exited; stop polling and fail below + with _reserve_port() as port: + log_path = os.path.join(data, "serve.log") + with open(log_path, "wb") as log: + proc = subprocess.Popen( + [binary, "serve", "--insecure-cookies", "--http-port", str(port)], + env=_env(data), stdout=log, stderr=subprocess.STDOUT, + ) + for _ in range(50): + if proc.poll() is not None: + break # process already exited; stop polling and fail below + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return proc, f"http://127.0.0.1:{port}" + except OSError: + time.sleep(0.1) + # Never became reachable — clean up the subprocess and fail loudly with its output. + proc.terminate() try: - with socket.create_connection(("127.0.0.1", port), timeout=0.2): - return proc, f"http://127.0.0.1:{port}" - except OSError: - time.sleep(0.1) - # Never became reachable — clean up the subprocess and fail loudly with its output. - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait(timeout=5) - output = pathlib.Path(log_path).read_text(errors="replace") if pathlib.Path(log_path).exists() else "" - pytest.fail(f"serve on port {port} never became reachable (exit={proc.returncode}):\n{output}") + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + output = pathlib.Path(log_path).read_text(errors="replace") if pathlib.Path(log_path).exists() else "" + pytest.fail(f"serve on port {port} never became reachable (exit={proc.returncode}):\n{output}") def _get_json(url): diff --git a/tests/admin/test_import_external_auths.py b/tests/admin/test_import_external_auths.py index 4b2679bd..114dffc2 100644 --- a/tests/admin/test_import_external_auths.py +++ b/tests/admin/test_import_external_auths.py @@ -34,6 +34,7 @@ pinned on its own by `src/auth.zig`'s "strip a client-supplied externalAuths" test — this one stays green if only that strip regresses.) """ +from conftest import _reserve_port import json import os import pathlib @@ -58,12 +59,6 @@ SU = {"identity": "admin@x.io", "password": "adminpassword"} -def _free_port(): - s = socket.socket() - s.bind(("127.0.0.1", 0)) - p = s.getsockname()[1] - s.close() - return p def _http(method, url, token=None, body=None): @@ -88,27 +83,27 @@ def __init__(self, binary, data): self.binary, self.data = binary, data def __enter__(self): - port = _free_port() - self.log = os.path.join(self.data, "serve.log") - with open(self.log, "ab") as log: - self.proc = subprocess.Popen( - [self.binary, "serve", "--insecure-cookies", "--http-port", str(port)], - # Foreground pin: see conftest's note on ZIGBASE_SERVE_BACKGROUND. - env={**os.environ, "ZIGBASE_DATA_DIR": self.data, - "ZIGBASE_SERVE_BACKGROUND": "0"}, - stdout=log, stderr=subprocess.STDOUT, - ) - for _ in range(50): - if self.proc.poll() is not None: - break - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.2): - return f"http://127.0.0.1:{port}" - except OSError: - time.sleep(0.1) - self.__exit__(None, None, None) - output = pathlib.Path(self.log).read_text(errors="replace") - pytest.fail(f"serve on port {port} never became reachable:\n{output}") + with _reserve_port() as port: + self.log = os.path.join(self.data, "serve.log") + with open(self.log, "ab") as log: + self.proc = subprocess.Popen( + [self.binary, "serve", "--insecure-cookies", "--http-port", str(port)], + # Foreground pin: see conftest's note on ZIGBASE_SERVE_BACKGROUND. + env={**os.environ, "ZIGBASE_DATA_DIR": self.data, + "ZIGBASE_SERVE_BACKGROUND": "0"}, + stdout=log, stderr=subprocess.STDOUT, + ) + for _ in range(50): + if self.proc.poll() is not None: + break + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return f"http://127.0.0.1:{port}" + except OSError: + time.sleep(0.1) + self.__exit__(None, None, None) + output = pathlib.Path(self.log).read_text(errors="replace") + pytest.fail(f"serve on port {port} never became reachable:\n{output}") def __exit__(self, *_exc): self.proc.terminate() diff --git a/tests/admin/test_mail_unsubscribe.py b/tests/admin/test_mail_unsubscribe.py index 98dc36cf..3d893641 100644 --- a/tests/admin/test_mail_unsubscribe.py +++ b/tests/admin/test_mail_unsubscribe.py @@ -14,6 +14,7 @@ 3/6/7, which cover the full sendBulk -> pollOnce -> CaptureMailer + suppression path) cannot prove: live HTTP semantics and superuser-vs-anonymous authz through the records API. """ +from conftest import _reserve_port, _stop_server, _wait_reachable_or_fail import base64 import hashlib @@ -21,11 +22,8 @@ import json import os import pathlib -import shutil -import socket import subprocess import tempfile -import time import pytest import urllib.error @@ -37,12 +35,6 @@ JWT_SECRET = "e2e-unsub-secret-0123456789abcdef" -def _free_port(): - s = socket.socket() - s.bind(("127.0.0.1", 0)) - p = s.getsockname()[1] - s.close() - return p def _http(method, url, body=None, token=None): @@ -72,27 +64,22 @@ def mint_token(secret: str, account: str, list_name: str, recipient: str) -> str @pytest.fixture(scope="module") def unsub_server(): binary = resolve_binary("ZIGBASE_TEST_BINARY", REPO, "zigbase") - data = tempfile.mkdtemp(prefix="zb_unsub_") - env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_JWT_SECRET": JWT_SECRET} - subprocess.run([binary, "superuser", "create", "--email", "admin@x.io", - "--password", "adminpassword", "--data-dir", data], check=True, env=env) - port = _free_port() - env["ZIGBASE_HTTP_PORT"] = str(port) - env["ZIGBASE_UNSUBSCRIBE_BASE_URL"] = f"http://127.0.0.1:{port}" - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - for _ in range(100): + with tempfile.TemporaryDirectory(prefix="zb_unsub_") as data, _reserve_port() as port: + env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_JWT_SECRET": JWT_SECRET} + subprocess.run([binary, "superuser", "create", "--email", "admin@x.io", + "--password", "adminpassword", "--data-dir", data], check=True, env=env) + env["ZIGBASE_HTTP_PORT"] = str(port) + env["ZIGBASE_SERVE_BACKGROUND"] = "0" + env["ZIGBASE_UNSUBSCRIBE_BASE_URL"] = f"http://127.0.0.1:{port}" + log_path = pathlib.Path(data) / "server.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, + stdout=log, stderr=subprocess.STDOUT) try: - with socket.create_connection(("127.0.0.1", port), timeout=0.2): - break - except OSError: - time.sleep(0.1) - try: - yield f"http://127.0.0.1:{port}" - finally: - proc.terminate() - proc.wait(timeout=5) - shutil.rmtree(data, ignore_errors=True) + _wait_reachable_or_fail(proc, port, log_path, timeout_s=10.0) + yield f"http://127.0.0.1:{port}" + finally: + _stop_server(proc) def _su_token(base): diff --git a/tests/admin/test_memory_jobs.py b/tests/admin/test_memory_jobs.py index 9a6c84f3..0702504a 100644 --- a/tests/admin/test_memory_jobs.py +++ b/tests/admin/test_memory_jobs.py @@ -7,7 +7,7 @@ import urllib.request import pytest -from conftest import _free_port, _wait_reachable_or_fail +from conftest import _reserve_port, _wait_reachable_or_fail def test_live_memory_pool_uses_configured_workers(tmp_path): @@ -21,38 +21,38 @@ def test_live_memory_pool_uses_configured_workers(tmp_path): assert report["envelope"]["scheduler_stack_bytes"] == 0 assert report["job_workers"] == 8 assert not report["scheduler_enabled"] - port = _free_port() - log_path = tmp_path / "server.log" - with log_path.open("w+") as log: - process = subprocess.Popen([binary, "serve", "--insecure-cookies", "--http-port", str(port), "--data-dir", str(tmp_path / "data")], env={**os.environ, "ZIGBASE_SERVE_BACKGROUND": "false"}, stdout=log, stderr=log) - base = f"http://127.0.0.1:{port}/api/test-jobs" + with _reserve_port() as port: + log_path = tmp_path / "server.log" + with log_path.open("w+") as log: + process = subprocess.Popen([binary, "serve", "--insecure-cookies", "--http-port", str(port), "--data-dir", str(tmp_path / "data")], env={**os.environ, "ZIGBASE_SERVE_BACKGROUND": "false"}, stdout=log, stderr=log) + base = f"http://127.0.0.1:{port}/api/test-jobs" - def call(path, method="GET"): - with urllib.request.urlopen(urllib.request.Request(base + path, method=method), timeout=5) as response: - return json.loads(response.read() or b"null") + def call(path, method="GET"): + with urllib.request.urlopen(urllib.request.Request(base + path, method=method), timeout=5) as response: + return json.loads(response.read() or b"null") - try: - _wait_reachable_or_fail(process, port, log_path) - assert call("/state") == {"entered": 0, "completed": 0} - call("/enqueue", "POST") - deadline = time.monotonic() + 5 - while call("/state")["entered"] == 0 and time.monotonic() < deadline: - time.sleep(0.01) - # Keep the gate closed long enough for any unintended extra worker - # to enter; observing the first worker alone is not sufficient. - deadline = time.monotonic() + 0.2 - while time.monotonic() < deadline: + try: + _wait_reachable_or_fail(process, port, log_path) + assert call("/state") == {"entered": 0, "completed": 0} + call("/enqueue", "POST") + deadline = time.monotonic() + 5 + while call("/state")["entered"] == 0 and time.monotonic() < deadline: + time.sleep(0.01) + # Keep the gate closed long enough for any unintended extra worker + # to enter; observing the first worker alone is not sufficient. + deadline = time.monotonic() + 0.2 + while time.monotonic() < deadline: + assert call("/state") == {"entered": 1, "completed": 0} + time.sleep(0.01) assert call("/state") == {"entered": 1, "completed": 0} - time.sleep(0.01) - assert call("/state") == {"entered": 1, "completed": 0} - call("/release", "POST") - deadline = time.monotonic() + 5 - while call("/state")["completed"] < 2 and time.monotonic() < deadline: - time.sleep(0.01) - assert call("/state") == {"entered": 2, "completed": 2} - finally: - if process.poll() is None: - with contextlib.suppress(Exception): - call("/release", "POST") - process.terminate() - process.wait(timeout=10) + call("/release", "POST") + deadline = time.monotonic() + 5 + while call("/state")["completed"] < 2 and time.monotonic() < deadline: + time.sleep(0.01) + assert call("/state") == {"entered": 2, "completed": 2} + finally: + if process.poll() is None: + with contextlib.suppress(Exception): + call("/release", "POST") + process.terminate() + process.wait(timeout=10) diff --git a/tests/admin/test_query_workbench_postgres.py b/tests/admin/test_query_workbench_postgres.py index fe58dad5..0478b5c1 100644 --- a/tests/admin/test_query_workbench_postgres.py +++ b/tests/admin/test_query_workbench_postgres.py @@ -10,7 +10,7 @@ import pytest -from conftest import _free_port, _stop_server, _wait_reachable_or_fail +from conftest import _reserve_port, _stop_server, _wait_reachable_or_fail from test_query_workbench import call @@ -22,68 +22,68 @@ def test_postgres_workbench_live(enabled, tmp_path): if not url or not binary: pytest.skip("requires live PostgreSQL and paired query-workbench fixtures") assert pathlib.Path(binary).is_file() - port = _free_port() - env = {**os.environ, "ZIGBASE_DB_URL": url, "ZIGBASE_DATA_DIR": str(tmp_path), - "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} - email = f"workbench-{uuid.uuid4().hex}@x.io" - created = subprocess.run([binary, "superuser", "create", "--email", email, - "--password", "adminpassword"], env=env, check=True, - capture_output=True, text=True, timeout=30) - assert "superuser created:" in created.stderr - log_path = tmp_path / "server.log" - with log_path.open("wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, - stdout=log, stderr=subprocess.STDOUT) - try: - _wait_reachable_or_fail(proc, port, str(log_path)) - base = f"http://127.0.0.1:{port}" - assert call(base, "GET", "/work/private-path")[0] == 204 - if not enabled: + with _reserve_port() as port: + env = {**os.environ, "ZIGBASE_DB_URL": url, "ZIGBASE_DATA_DIR": str(tmp_path), + "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} + email = f"workbench-{uuid.uuid4().hex}@x.io" + created = subprocess.run([binary, "superuser", "create", "--email", email, + "--password", "adminpassword"], env=env, check=True, + capture_output=True, text=True, timeout=30) + assert "superuser created:" in created.stderr + log_path = tmp_path / "server.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, + stdout=log, stderr=subprocess.STDOUT) + try: + _wait_reachable_or_fail(proc, port, str(log_path)) + base = f"http://127.0.0.1:{port}" + assert call(base, "GET", "/work/private-path")[0] == 204 + if not enabled: + for method, path in [("GET", "stats"), ("POST", "explain")]: + assert call(base, method, f"/api/query-workbench/{path}")[0] == 404 + assert call(base, "GET", "/api/meta")[1]["capabilities"]["queryWorkbench"] is False + return + code, body = call(base, "POST", "/api/collections/_superusers/auth-with-password", + {"identity": email, "password": "adminpassword"}) + assert code == 200, body + token = body["token"] for method, path in [("GET", "stats"), ("POST", "explain")]: - assert call(base, method, f"/api/query-workbench/{path}")[0] == 404 - assert call(base, "GET", "/api/meta")[1]["capabilities"]["queryWorkbench"] is False - return - code, body = call(base, "POST", "/api/collections/_superusers/auth-with-password", - {"identity": email, "password": "adminpassword"}) - assert code == 200, body - token = body["token"] - for method, path in [("GET", "stats"), ("POST", "explain")]: - endpoint = f"/api/query-workbench/{path}" - assert call(base, method, endpoint)[0] == 401 - assert call(base, method, endpoint, headers={"Cookie": f"zb_auth={token}"})[0] == 401 - assert call(base, "POST", "/api/query-workbench/explain", {"collection": "_superusers"}, token)[0] == 501 - assert call(base, "GET", "/held")[0] == 204 - assert call(base, "GET", "/pool-wait")[0] == 204 - code, report = call(base, "GET", "/api/query-workbench/stats", token=token) - assert code == 200 and report["activeBackend"] == "postgres" - route = next(row for row in report["routes"] if row["routeTemplate"] == "/work/:id") - assert route["completedScopes"] == 1 - assert route["totalNanoseconds"] == route["maxNanoseconds"] - work = next(item for item in report["items"] if item["routeTemplate"] == "/work/:id") - assert work["backend"] == "postgres" - assert work["measurement"] == "client-extended-protocol-exchange-time" - assert work["executions"] == work["finalizedStatements"] == 3 - assert work["repeatedShapes"] == 2 - wait_route = next(row for row in report["routes"] if row["routeTemplate"] == "/pool-wait") - wait = next(row for row in wait_route["poolWaits"] if row["backend"] == "postgres" and row["role"] == "writer") - assert wait["acquisitions"] == 1 - assert wait["totalNanoseconds"] == wait["maxNanoseconds"] >= 50_000_000 - assert wait_route["responseStatusClasses"]["success"] == 1 - held = next(item for item in report["items"] if item["routeTemplate"] == "/held") - assert held["executions"] == 2 and held["finalizedStatements"] == 1 - assert held["heldNanoseconds"] >= 40_000_000 - assert held["statementLifetimeNanoseconds"] == held["measuredCallNanoseconds"] + held["heldNanoseconds"] - for secret in ("private-path", "private-literal", email, token, "SELECT"): - assert secret not in str(report) - assert call(base, "GET", "/api/query-workbench/stats", token=token)[1] == report - members = "workbench_members_" + uuid.uuid4().hex[:12] - assert call(base, "POST", "/api/collections", {"name": members, "type": "auth", "fields": []}, token)[0] == 201 - assert call(base, "POST", f"/api/collections/{members}/records", - {"email": "member@x.io", "password": "memberpassword"}, token)[0] == 201 - code, member = call(base, "POST", f"/api/collections/{members}/auth-with-password", - {"identity": "member@x.io", "password": "memberpassword"}) - assert code == 200, member - for method, path in [("GET", "stats"), ("POST", "explain")]: - assert call(base, method, f"/api/query-workbench/{path}", token=member["token"])[0] == 403 - finally: - _stop_server(proc) + endpoint = f"/api/query-workbench/{path}" + assert call(base, method, endpoint)[0] == 401 + assert call(base, method, endpoint, headers={"Cookie": f"zb_auth={token}"})[0] == 401 + assert call(base, "POST", "/api/query-workbench/explain", {"collection": "_superusers"}, token)[0] == 501 + assert call(base, "GET", "/held")[0] == 204 + assert call(base, "GET", "/pool-wait")[0] == 204 + code, report = call(base, "GET", "/api/query-workbench/stats", token=token) + assert code == 200 and report["activeBackend"] == "postgres" + route = next(row for row in report["routes"] if row["routeTemplate"] == "/work/:id") + assert route["completedScopes"] == 1 + assert route["totalNanoseconds"] == route["maxNanoseconds"] + work = next(item for item in report["items"] if item["routeTemplate"] == "/work/:id") + assert work["backend"] == "postgres" + assert work["measurement"] == "client-extended-protocol-exchange-time" + assert work["executions"] == work["finalizedStatements"] == 3 + assert work["repeatedShapes"] == 2 + wait_route = next(row for row in report["routes"] if row["routeTemplate"] == "/pool-wait") + wait = next(row for row in wait_route["poolWaits"] if row["backend"] == "postgres" and row["role"] == "writer") + assert wait["acquisitions"] == 1 + assert wait["totalNanoseconds"] == wait["maxNanoseconds"] >= 50_000_000 + assert wait_route["responseStatusClasses"]["success"] == 1 + held = next(item for item in report["items"] if item["routeTemplate"] == "/held") + assert held["executions"] == 2 and held["finalizedStatements"] == 1 + assert held["heldNanoseconds"] >= 40_000_000 + assert held["statementLifetimeNanoseconds"] == held["measuredCallNanoseconds"] + held["heldNanoseconds"] + for secret in ("private-path", "private-literal", email, token, "SELECT"): + assert secret not in str(report) + assert call(base, "GET", "/api/query-workbench/stats", token=token)[1] == report + members = "workbench_members_" + uuid.uuid4().hex[:12] + assert call(base, "POST", "/api/collections", {"name": members, "type": "auth", "fields": []}, token)[0] == 201 + assert call(base, "POST", f"/api/collections/{members}/records", + {"email": "member@x.io", "password": "memberpassword"}, token)[0] == 201 + code, member = call(base, "POST", f"/api/collections/{members}/auth-with-password", + {"identity": "member@x.io", "password": "memberpassword"}) + assert code == 200, member + for method, path in [("GET", "stats"), ("POST", "explain")]: + assert call(base, method, f"/api/query-workbench/{path}", token=member["token"])[0] == 403 + finally: + _stop_server(proc) diff --git a/tests/admin/test_rest_idempotency.py b/tests/admin/test_rest_idempotency.py index 2da334fb..8a7c5ccf 100644 --- a/tests/admin/test_rest_idempotency.py +++ b/tests/admin/test_rest_idempotency.py @@ -78,7 +78,7 @@ def test_current_identity_rules_schema_and_bounds(server): @pytest.mark.parametrize("backend", ["sqlite", "postgres"]) def test_receipt_survives_restart_and_second_process(binary, tmp_path, backend): import subprocess - from conftest import _free_port, _stop_server, _wait_reachable_or_fail + from conftest import _reserve_port, _stop_server, _wait_reachable_or_fail url = os.environ.get("ZIGBASE_REST_PG_TEST_URL") if backend == "postgres" else None if backend == "postgres" and not url: pytest.skip("requires dedicated disposable ZIGBASE_REST_PG_TEST_URL") @@ -89,13 +89,13 @@ def test_receipt_survives_restart_and_second_process(binary, tmp_path, backend): processes = [] def start(): - port = _free_port() - log_path = tmp_path / f"server-{port}.log" - with log_path.open("wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies", "--ignore-lock"], env={**env, "ZIGBASE_HTTP_PORT": str(port)}, stdout=log, stderr=subprocess.STDOUT) - processes.append(proc) - _wait_reachable_or_fail(proc, port, str(log_path)) - return f"http://127.0.0.1:{port}" + with _reserve_port() as port: + log_path = tmp_path / f"server-{port}.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies", "--ignore-lock"], env={**env, "ZIGBASE_HTTP_PORT": str(port)}, stdout=log, stderr=subprocess.STDOUT) + processes.append(proc) + _wait_reachable_or_fail(proc, port, str(log_path)) + return f"http://127.0.0.1:{port}" try: first = start() @@ -145,26 +145,26 @@ def test_unsupported_collections_and_failed_mutation_do_not_consume_key(server): def test_unavailable_feature_refuses_key_without_writing(tmp_path): import subprocess - from conftest import _free_port, _stop_server, _wait_reachable_or_fail + from conftest import _reserve_port, _stop_server, _wait_reachable_or_fail binary = os.environ.get("ZIGBASE_TEST_REST_IDEMPOTENCY_OFF_BINARY") if not binary: pytest.skip("requires default-off server binary") - port = _free_port() - env = {**os.environ, "ZIGBASE_DATA_DIR": str(tmp_path), "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} - subprocess.run([binary, "superuser", "create", "--email", "admin@x.io", "--password", "adminpassword"], env=env, check=True, capture_output=True, timeout=30) - log_path = tmp_path / "server.log" - with log_path.open("wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, stdout=log, stderr=subprocess.STDOUT) - try: - _wait_reachable_or_fail(proc, port, str(log_path)) - base = f"http://127.0.0.1:{port}" - admin, token, _, _ = setup(base) - path = "/api/collections/posts/records" - result = call(base, "POST", path, {"title": "refuse"}, token, "off") - assert result[0] == 400, result - assert call(base, "GET", path, token=admin)[1]["items"] == [] - finally: - _stop_server(proc) + with _reserve_port() as port: + env = {**os.environ, "ZIGBASE_DATA_DIR": str(tmp_path), "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} + subprocess.run([binary, "superuser", "create", "--email", "admin@x.io", "--password", "adminpassword"], env=env, check=True, capture_output=True, timeout=30) + log_path = tmp_path / "server.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, stdout=log, stderr=subprocess.STDOUT) + try: + _wait_reachable_or_fail(proc, port, str(log_path)) + base = f"http://127.0.0.1:{port}" + admin, token, _, _ = setup(base) + path = "/api/collections/posts/records" + result = call(base, "POST", path, {"title": "refuse"}, token, "off") + assert result[0] == 400, result + assert call(base, "GET", path, token=admin)[1]["items"] == [] + finally: + _stop_server(proc) @pytest.mark.parametrize("replay", [False, True], ids=["fresh", "replay"]) @@ -173,108 +173,108 @@ def test_postgres_rule_writer_blocks_receipt_authorization(binary, tmp_path, rep """A concurrent rule change commits before fresh receipt authorization runs.""" import subprocess import time - from conftest import _free_port, _stop_server, _wait_reachable_or_fail + from conftest import _reserve_port, _stop_server, _wait_reachable_or_fail url = os.environ.get("ZIGBASE_REST_PG_TEST_URL") if not url: pytest.skip("requires dedicated disposable ZIGBASE_REST_PG_TEST_URL") import psycopg - port = _free_port() - env = {**os.environ, "ZIGBASE_DB_URL": url, "ZIGBASE_DATA_DIR": str(tmp_path), - "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} - # The preceding PostgreSQL restart test may already have bootstrapped this user. - subprocess.run([binary, "superuser", "create", "--email", "admin@x.io", - "--password", "adminpassword"], env=env, capture_output=True, timeout=30) - log_path = tmp_path / "schema-coordination.log" - with log_path.open("wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, - stdout=log, stderr=subprocess.STDOUT) - base = f"http://127.0.0.1:{port}" - original_rule = None - original_auth_id = None - replacement_auth_id = None - restore_rule = False - try: - _wait_reachable_or_fail(proc, port, str(log_path)) - admin, token, _, collection = setup(base, reuse=True) - original_rule = collection["createRule"] - status, auth_collection, _ = call(base, "GET", "/api/collections/users", token=admin) - assert status == 200, auth_collection - original_auth_id = auth_collection["id"] - import uuid - replacement_auth_id = uuid.uuid4().hex[:15] - restore_rule = True - key = "schema-race-replay" if replay else "schema-race-fresh" - if replay: - result = call(base, "POST", "/api/collections/posts/records", - {"title": "must-not-commit"}, token, key) - assert result[0] == 201, result - with psycopg.connect(url, autocommit=True) as writer, psycopg.connect(url, autocommit=True) as observer: - def receipt_count(): - if observer.execute("SELECT to_regclass('_idempotency_receipts')").fetchone()[0] is None: - return 0 - return observer.execute("SELECT count(*) FROM _idempotency_receipts").fetchone()[0] - - before_receipts = receipt_count() - before_records = observer.execute("SELECT count(*) FROM posts").fetchone()[0] - writer.execute("BEGIN") - writer.execute('UPDATE "_schema_state" SET generation=generation WHERE id=1') - if change == "rule": - writer.execute('UPDATE "_collections" SET "createRule"=NULL WHERE name=%s', ("posts",)) - else: - # Model a replacement/import retaining auth table name and token - # bytes while changing the collection identity used by receipts. - writer.execute('UPDATE "_collections" SET id=%s WHERE name=%s', (replacement_auth_id, "users")) - writer.execute('UPDATE "_storage_namespaces" SET collection_id=%s WHERE collection_id=%s', - (replacement_auth_id, original_auth_id)) - writer.execute('UPDATE "_schema_state" SET generation=generation+1 WHERE id=1') - writer_pid = writer.info.backend_pid - try: - with ThreadPoolExecutor(max_workers=1) as executor: - pending = executor.submit(call, base, "POST", "/api/collections/posts/records", - {"title": "must-not-commit"}, token, key) - try: - deadline = time.monotonic() + 5 - blocked = False - while time.monotonic() < deadline: - blocked = observer.execute( - "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() " - "AND %s=ANY(pg_blocking_pids(pid)) AND query LIKE %s)", - (writer_pid, 'UPDATE "_schema_state"%'), - ).fetchone()[0] - if blocked or pending.done(): - break - time.sleep(0.02) - assert blocked, "REST mutation did not wait for the concurrent metadata writer" - assert not pending.done() - finally: - # Always release the request, including on a failed assertion. - writer.execute("COMMIT") - status, body, _ = pending.result(timeout=10) - assert status == (409 if change == "rule" else 401), body - finally: - writer.execute("ROLLBACK") - assert observer.execute('SELECT count(*) FROM posts').fetchone()[0] == before_records - assert receipt_count() == before_receipts - # authorize runs before receipt lookup/fingerprint comparison. The now - # locked rule returns 403 for both fresh and replay attempts, not 409. - if change == "rule": - assert call(base, "POST", "/api/collections/posts/records", - {"title": "must-not-commit"}, token, key)[0] == 403 - finally: + with _reserve_port() as port: + env = {**os.environ, "ZIGBASE_DB_URL": url, "ZIGBASE_DATA_DIR": str(tmp_path), + "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} + # The preceding PostgreSQL restart test may already have bootstrapped this user. + subprocess.run([binary, "superuser", "create", "--email", "admin@x.io", + "--password", "adminpassword"], env=env, capture_output=True, timeout=30) + log_path = tmp_path / "schema-coordination.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, + stdout=log, stderr=subprocess.STDOUT) + base = f"http://127.0.0.1:{port}" + original_rule = None + original_auth_id = None + replacement_auth_id = None + restore_rule = False try: - if restore_rule: - with psycopg.connect(url) as cleanup: - cleanup.execute('UPDATE "_schema_state" SET generation=generation WHERE id=1') - cleanup.execute('UPDATE "_collections" SET "createRule"=%s WHERE name=%s', (original_rule, "posts")) - if change == "auth_collection_id": - cleanup.execute('UPDATE "_collections" SET id=%s WHERE name=%s', (original_auth_id, "users")) - cleanup.execute('UPDATE "_storage_namespaces" SET collection_id=%s WHERE collection_id=%s', - (original_auth_id, replacement_auth_id)) - cleanup.execute('UPDATE "_schema_state" SET generation=generation+1 WHERE id=1') + _wait_reachable_or_fail(proc, port, str(log_path)) + admin, token, _, collection = setup(base, reuse=True) + original_rule = collection["createRule"] + status, auth_collection, _ = call(base, "GET", "/api/collections/users", token=admin) + assert status == 200, auth_collection + original_auth_id = auth_collection["id"] + import uuid + replacement_auth_id = uuid.uuid4().hex[:15] + restore_rule = True + key = "schema-race-replay" if replay else "schema-race-fresh" + if replay: + result = call(base, "POST", "/api/collections/posts/records", + {"title": "must-not-commit"}, token, key) + assert result[0] == 201, result + with psycopg.connect(url, autocommit=True) as writer, psycopg.connect(url, autocommit=True) as observer: + def receipt_count(): + if observer.execute("SELECT to_regclass('_idempotency_receipts')").fetchone()[0] is None: + return 0 + return observer.execute("SELECT count(*) FROM _idempotency_receipts").fetchone()[0] + + before_receipts = receipt_count() + before_records = observer.execute("SELECT count(*) FROM posts").fetchone()[0] + writer.execute("BEGIN") + writer.execute('UPDATE "_schema_state" SET generation=generation WHERE id=1') + if change == "rule": + writer.execute('UPDATE "_collections" SET "createRule"=NULL WHERE name=%s', ("posts",)) + else: + # Model a replacement/import retaining auth table name and token + # bytes while changing the collection identity used by receipts. + writer.execute('UPDATE "_collections" SET id=%s WHERE name=%s', (replacement_auth_id, "users")) + writer.execute('UPDATE "_storage_namespaces" SET collection_id=%s WHERE collection_id=%s', + (replacement_auth_id, original_auth_id)) + writer.execute('UPDATE "_schema_state" SET generation=generation+1 WHERE id=1') + writer_pid = writer.info.backend_pid + try: + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit(call, base, "POST", "/api/collections/posts/records", + {"title": "must-not-commit"}, token, key) + try: + deadline = time.monotonic() + 5 + blocked = False + while time.monotonic() < deadline: + blocked = observer.execute( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() " + "AND %s=ANY(pg_blocking_pids(pid)) AND query LIKE %s)", + (writer_pid, 'UPDATE "_schema_state"%'), + ).fetchone()[0] + if blocked or pending.done(): + break + time.sleep(0.02) + assert blocked, "REST mutation did not wait for the concurrent metadata writer" + assert not pending.done() + finally: + # Always release the request, including on a failed assertion. + writer.execute("COMMIT") + status, body, _ = pending.result(timeout=10) + assert status == (409 if change == "rule" else 401), body + finally: + writer.execute("ROLLBACK") + assert observer.execute('SELECT count(*) FROM posts').fetchone()[0] == before_records + assert receipt_count() == before_receipts + # authorize runs before receipt lookup/fingerprint comparison. The now + # locked rule returns 403 for both fresh and replay attempts, not 409. + if change == "rule": + assert call(base, "POST", "/api/collections/posts/records", + {"title": "must-not-commit"}, token, key)[0] == 403 finally: - _stop_server(proc) + try: + if restore_rule: + with psycopg.connect(url) as cleanup: + cleanup.execute('UPDATE "_schema_state" SET generation=generation WHERE id=1') + cleanup.execute('UPDATE "_collections" SET "createRule"=%s WHERE name=%s', (original_rule, "posts")) + if change == "auth_collection_id": + cleanup.execute('UPDATE "_collections" SET id=%s WHERE name=%s', (original_auth_id, "users")) + cleanup.execute('UPDATE "_storage_namespaces" SET collection_id=%s WHERE collection_id=%s', + (original_auth_id, replacement_auth_id)) + cleanup.execute('UPDATE "_schema_state" SET generation=generation+1 WHERE id=1') + finally: + _stop_server(proc) @pytest.mark.parametrize("operation", ["create", "update"]) @@ -282,69 +282,69 @@ def test_postgres_replay_waits_for_concurrent_record_writer(binary, tmp_path, op """A replay may not return its old body while a row mutation is in flight.""" import subprocess import time - from conftest import _free_port, _stop_server, _wait_reachable_or_fail + from conftest import _reserve_port, _stop_server, _wait_reachable_or_fail url = os.environ.get("ZIGBASE_REST_PG_TEST_URL") if not url: pytest.skip("requires dedicated disposable ZIGBASE_REST_PG_TEST_URL") import psycopg - port = _free_port() - env = {**os.environ, "ZIGBASE_DB_URL": url, "ZIGBASE_DATA_DIR": str(tmp_path), - "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} - subprocess.run([binary, "superuser", "create", "--email", "admin@x.io", - "--password", "adminpassword"], env=env, capture_output=True, timeout=30) - log_path = tmp_path / "row-coordination.log" - with log_path.open("wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, - stdout=log, stderr=subprocess.STDOUT) - base = f"http://127.0.0.1:{port}" - try: - _wait_reachable_or_fail(proc, port, str(log_path)) - admin, token, _, _ = setup(base, reuse=True) - path = "/api/collections/posts/records" - method = "POST" - if operation == "update": - status, original, _ = call(base, "POST", path, {"title": "initial"}, admin) - assert status == 201, original - path += "/" + original["id"] - method = "PATCH" - key = "row-lock-" + operation - status, receipt, _ = call(base, method, path, {"title": "receipt-body"}, token, key) - assert status == (201 if operation == "create" else 200), receipt - with psycopg.connect(url, autocommit=True) as writer, psycopg.connect(url, autocommit=True) as observer: - before_receipts = observer.execute("SELECT count(*) FROM _idempotency_receipts").fetchone()[0] - writer.execute("BEGIN") - writer.execute('UPDATE posts SET title=%s WHERE id=%s', ("concurrent-body", receipt["id"])) - writer_pid = writer.info.backend_pid - try: - with ThreadPoolExecutor(max_workers=1) as executor: - pending = executor.submit(call, base, method, path, - {"title": "receipt-body"}, token, key) - try: - deadline = time.monotonic() + 5 - blocked = False - while time.monotonic() < deadline: - blocked = observer.execute( - "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() " - "AND %s=ANY(pg_blocking_pids(pid)) AND query LIKE %s)", - (writer_pid, '%FOR UPDATE%'), - ).fetchone()[0] - if blocked or pending.done(): - break - time.sleep(0.02) - assert blocked, "REST replay did not wait for the concurrent record writer" - assert not pending.done() - finally: - writer.execute("COMMIT") - status, body, _ = pending.result(timeout=10) - assert status == 409, body - finally: - writer.execute("ROLLBACK") - assert observer.execute("SELECT title FROM posts WHERE id=%s", (receipt["id"],)).fetchone()[0] == "concurrent-body" - assert observer.execute("SELECT count(*) FROM _idempotency_receipts").fetchone()[0] == before_receipts - finally: - _stop_server(proc) + with _reserve_port() as port: + env = {**os.environ, "ZIGBASE_DB_URL": url, "ZIGBASE_DATA_DIR": str(tmp_path), + "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} + subprocess.run([binary, "superuser", "create", "--email", "admin@x.io", + "--password", "adminpassword"], env=env, capture_output=True, timeout=30) + log_path = tmp_path / "row-coordination.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, + stdout=log, stderr=subprocess.STDOUT) + base = f"http://127.0.0.1:{port}" + try: + _wait_reachable_or_fail(proc, port, str(log_path)) + admin, token, _, _ = setup(base, reuse=True) + path = "/api/collections/posts/records" + method = "POST" + if operation == "update": + status, original, _ = call(base, "POST", path, {"title": "initial"}, admin) + assert status == 201, original + path += "/" + original["id"] + method = "PATCH" + key = "row-lock-" + operation + status, receipt, _ = call(base, method, path, {"title": "receipt-body"}, token, key) + assert status == (201 if operation == "create" else 200), receipt + with psycopg.connect(url, autocommit=True) as writer, psycopg.connect(url, autocommit=True) as observer: + before_receipts = observer.execute("SELECT count(*) FROM _idempotency_receipts").fetchone()[0] + writer.execute("BEGIN") + writer.execute('UPDATE posts SET title=%s WHERE id=%s', ("concurrent-body", receipt["id"])) + writer_pid = writer.info.backend_pid + try: + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit(call, base, method, path, + {"title": "receipt-body"}, token, key) + try: + deadline = time.monotonic() + 5 + blocked = False + while time.monotonic() < deadline: + blocked = observer.execute( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() " + "AND %s=ANY(pg_blocking_pids(pid)) AND query LIKE %s)", + (writer_pid, '%FOR UPDATE%'), + ).fetchone()[0] + if blocked or pending.done(): + break + time.sleep(0.02) + assert blocked, "REST replay did not wait for the concurrent record writer" + assert not pending.done() + finally: + writer.execute("COMMIT") + status, body, _ = pending.result(timeout=10) + assert status == 409, body + finally: + writer.execute("ROLLBACK") + assert observer.execute("SELECT title FROM posts WHERE id=%s", (receipt["id"],)).fetchone()[0] == "concurrent-body" + assert observer.execute("SELECT count(*) FROM _idempotency_receipts").fetchone()[0] == before_receipts + finally: + _stop_server(proc) @pytest.mark.parametrize("operation", ["create", "update"]) diff --git a/tests/admin/test_rest_retry_integration.py b/tests/admin/test_rest_retry_integration.py index f66e9e70..c4411f37 100644 --- a/tests/admin/test_rest_retry_integration.py +++ b/tests/admin/test_rest_retry_integration.py @@ -7,7 +7,7 @@ import pytest -from conftest import _free_port, _stop_server, _su_template_for, _wait_reachable_or_fail +from conftest import _reserve_port, _stop_server, _su_template_for, _wait_reachable_or_fail from test_query_workbench import call from test_rest_idempotency import setup from test_realtime_backfill import backfill @@ -20,17 +20,17 @@ def app(tmp_path): pytest.skip("requires REST idempotency fixture with durable realtime and tenancy") assert Path(binary).is_file() shutil.copytree(_su_template_for(binary), tmp_path, dirs_exist_ok=True) - port = _free_port() - env = {k: v for k, v in os.environ.items() if not k.startswith("ZIGBASE_")} - env.update(ZIGBASE_DATA_DIR=str(tmp_path), ZIGBASE_HTTP_PORT=str(port), ZIGBASE_SERVE_BACKGROUND="0") - log_path = tmp_path / "server.log" - with log_path.open("wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, stdout=log, stderr=subprocess.STDOUT) - try: - _wait_reachable_or_fail(proc, port, str(log_path)) - yield f"http://127.0.0.1:{port}", tmp_path / "data.db" - finally: - _stop_server(proc) + with _reserve_port() as port: + env = {k: v for k, v in os.environ.items() if not k.startswith("ZIGBASE_")} + env.update(ZIGBASE_DATA_DIR=str(tmp_path), ZIGBASE_HTTP_PORT=str(port), ZIGBASE_SERVE_BACKGROUND="0") + log_path = tmp_path / "server.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, stdout=log, stderr=subprocess.STDOUT) + try: + _wait_reachable_or_fail(proc, port, str(log_path)) + yield f"http://127.0.0.1:{port}", tmp_path / "data.db" + finally: + _stop_server(proc) def test_retries_do_not_duplicate_journal_entries_and_capture_failure_rolls_back_receipt(app): diff --git a/tests/admin/test_resumable_uploads.py b/tests/admin/test_resumable_uploads.py index f6604989..d95a1585 100644 --- a/tests/admin/test_resumable_uploads.py +++ b/tests/admin/test_resumable_uploads.py @@ -10,7 +10,7 @@ import urllib.request import pytest -from conftest import _free_port, _su_template_for, _wait_reachable_or_fail +from conftest import _reserve_port, _su_template_for, _wait_reachable_or_fail @pytest.fixture(scope="session") @@ -72,37 +72,37 @@ def begin(server, token, record, length=4, filename="a.txt"): def test_automatic_rollback_does_not_retry_rollback(binary, tmp_path): data = tmp_path / "data" shutil.copytree(_su_template_for(binary), data) - port = _free_port() - base = f"http://127.0.0.1:{port}" - log_path = tmp_path / "server.log" - env = {**os.environ, "ZIGBASE_DATA_DIR": str(data), - "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} - with log_path.open("wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, - stdout=log, stderr=subprocess.STDOUT) - try: - _wait_reachable_or_fail(proc, port, str(log_path)) - admin, tokens, _, record = setup(base) - code, upload = begin(base, tokens[0], record) - assert code == 201, upload - path = "/api/uploads/" + upload["id"] - assert call(base, "PATCH", path, b"abcd", tokens[0], 0)[0] == 204 - with sqlite3.connect(data / "data.db") as conn: - conn.execute("CREATE TRIGGER automatic_rollback BEFORE UPDATE ON uploads " - "BEGIN SELECT RAISE(ROLLBACK, 'injected automatic rollback'); END") - assert call(base, "POST", path + "/commit", token=tokens[0])[0] >= 400 - with sqlite3.connect(data / "data.db") as conn: - conn.execute("DROP TRIGGER automatic_rollback") - assert conn.execute("SELECT file FROM uploads WHERE id=?", - (record["id"],)).fetchone() == (None,) - assert call(base, "PATCH", "/api/collections/uploads/records/" + record["id"], - {"title": "writer-still-usable"}, admin)[0] == 200 - assert "record update rollback failed" not in log_path.read_text() - assert proc.poll() is None - finally: - if proc.poll() is None: - proc.terminate() - proc.wait(timeout=10) + with _reserve_port() as port: + base = f"http://127.0.0.1:{port}" + log_path = tmp_path / "server.log" + env = {**os.environ, "ZIGBASE_DATA_DIR": str(data), + "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, + stdout=log, stderr=subprocess.STDOUT) + try: + _wait_reachable_or_fail(proc, port, str(log_path)) + admin, tokens, _, record = setup(base) + code, upload = begin(base, tokens[0], record) + assert code == 201, upload + path = "/api/uploads/" + upload["id"] + assert call(base, "PATCH", path, b"abcd", tokens[0], 0)[0] == 204 + with sqlite3.connect(data / "data.db") as conn: + conn.execute("CREATE TRIGGER automatic_rollback BEFORE UPDATE ON uploads " + "BEGIN SELECT RAISE(ROLLBACK, 'injected automatic rollback'); END") + assert call(base, "POST", path + "/commit", token=tokens[0])[0] >= 400 + with sqlite3.connect(data / "data.db") as conn: + conn.execute("DROP TRIGGER automatic_rollback") + assert conn.execute("SELECT file FROM uploads WHERE id=?", + (record["id"],)).fetchone() == (None,) + assert call(base, "PATCH", "/api/collections/uploads/records/" + record["id"], + {"title": "writer-still-usable"}, admin)[0] == 200 + assert "record update rollback failed" not in log_path.read_text() + assert proc.poll() is None + finally: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=10) def wait_for_expiry(expires_at): @@ -334,46 +334,47 @@ def test_disabled_http_routes(tmp_path): off = os.environ.get("ZIGBASE_TEST_UPLOADS_OFF_BINARY") if not off: pytest.skip("requires a default-build negative control") - port = _free_port() - env = {**os.environ, "ZIGBASE_DATA_DIR": str(tmp_path / "off"), "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} - log_path = tmp_path / "off.log" - with log_path.open("wb") as log: - proc = subprocess.Popen([off, "serve", "--insecure-cookies"], env=env, stdout=log, stderr=subprocess.STDOUT) - try: - _wait_reachable_or_fail(proc, port, str(log_path)) - server = f"http://127.0.0.1:{port}" - for method, path in [("POST", "/api/collections/x/records/y/uploads"), ("GET", "/api/uploads/x"), ("PATCH", "/api/uploads/x"), ("DELETE", "/api/uploads/x"), ("POST", "/api/uploads/x/commit")]: - assert call(server, method, path)[0] == 404 - finally: - proc.terminate() - proc.wait(timeout=10) + with _reserve_port() as port: + env = {**os.environ, "ZIGBASE_DATA_DIR": str(tmp_path / "off"), "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} + log_path = tmp_path / "off.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([off, "serve", "--insecure-cookies"], env=env, stdout=log, stderr=subprocess.STDOUT) + try: + _wait_reachable_or_fail(proc, port, str(log_path)) + server = f"http://127.0.0.1:{port}" + for method, path in [("POST", "/api/collections/x/records/y/uploads"), ("GET", "/api/uploads/x"), ("PATCH", "/api/uploads/x"), ("DELETE", "/api/uploads/x"), ("POST", "/api/uploads/x/commit")]: + assert call(server, method, path)[0] == 404 + finally: + proc.terminate() + proc.wait(timeout=10) def test_restart_invalidates_sessions_without_staging_files(binary, tmp_path): data = tmp_path / "data" shutil.copytree(_su_template_for(binary), data) - port = _free_port() - server = f"http://127.0.0.1:{port}" - env = {**os.environ, "ZIGBASE_DATA_DIR": str(data), "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} - log_path = tmp_path / "server.log" - def start(): - with log_path.open("ab") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, stdout=log, stderr=subprocess.STDOUT) - _wait_reachable_or_fail(proc, port, str(log_path)) - return proc - proc = start() - try: - _, tokens, _, record = setup(server) - _, upload = begin(server, tokens[0], record) - path = "/api/uploads/" + upload["id"] - assert call(server, "PATCH", path, b"ab", tokens[0], 0)[0] == 204 - finally: - proc.terminate() - proc.wait(timeout=10) - proc = start() - try: - assert call(server, "GET", path, token=tokens[0])[0] == 404 - assert not list((data / "storage").rglob("a.txt")) - finally: - proc.terminate() - proc.wait(timeout=10) + with _reserve_port() as port: + server = f"http://127.0.0.1:{port}" + env = {**os.environ, "ZIGBASE_DATA_DIR": str(data), "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} + log_path = tmp_path / "server.log" + def start(): + log_start = log_path.stat().st_size if log_path.exists() else 0 + with log_path.open("ab") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, stdout=log, stderr=subprocess.STDOUT) + _wait_reachable_or_fail(proc, port, str(log_path), log_start=log_start) + return proc + proc = start() + try: + _, tokens, _, record = setup(server) + _, upload = begin(server, tokens[0], record) + path = "/api/uploads/" + upload["id"] + assert call(server, "PATCH", path, b"ab", tokens[0], 0)[0] == 204 + finally: + proc.terminate() + proc.wait(timeout=10) + proc = start() + try: + assert call(server, "GET", path, token=tokens[0])[0] == 404 + assert not list((data / "storage").rglob("a.txt")) + finally: + proc.terminate() + proc.wait(timeout=10) diff --git a/tests/admin/test_scheduler.py b/tests/admin/test_scheduler.py index c000478a..a52cd116 100644 --- a/tests/admin/test_scheduler.py +++ b/tests/admin/test_scheduler.py @@ -1,36 +1,35 @@ +from conftest import _reserve_port import json, socket, subprocess, time, urllib.request, os, signal, tempfile, pathlib from _bin import resolve_binary REPO = pathlib.Path(__file__).resolve().parents[2] BLOG = REPO / "examples" / "blog" -def _free_port(): - s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p def test_server_runs_and_shuts_down_with_a_job_registered(): blog = resolve_binary("ZIGBASE_TEST_BLOG_BINARY", BLOG, "blog") with tempfile.TemporaryDirectory() as data: - port = _free_port() - proc = subprocess.Popen( - [str(blog), "serve", "--http-port", str(port), "--data-dir", data], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - url = f"http://127.0.0.1:{port}/api/blog/ping" - deadline = time.time() + 20 - status, body = None, None - while time.time() < deadline: - try: - with urllib.request.urlopen(url, timeout=1) as r: - status = r.status; body = r.read().decode(); break - except Exception: - time.sleep(0.2) - assert body is not None, "server with a scheduled job did not come up" - assert status == 200 and json.loads(body) == {"pong": True}, f"status={status} body={body}" - finally: - try: proc.send_signal(signal.SIGINT) - except Exception: pass - try: proc.terminate() - except Exception: pass - # clean shutdown: wait must complete (scheduler threads join) within the timeout - proc.wait(timeout=15) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(blog), "serve", "--http-port", str(port), "--data-dir", data], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + url = f"http://127.0.0.1:{port}/api/blog/ping" + deadline = time.time() + 20 + status, body = None, None + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as r: + status = r.status; body = r.read().decode(); break + except Exception: + time.sleep(0.2) + assert body is not None, "server with a scheduled job did not come up" + assert status == 200 and json.loads(body) == {"pong": True}, f"status={status} body={body}" + finally: + try: proc.send_signal(signal.SIGINT) + except Exception: pass + try: proc.terminate() + except Exception: pass + # clean shutdown: wait must complete (scheduler threads join) within the timeout + proc.wait(timeout=15) diff --git a/tests/admin/test_server_ownership.py b/tests/admin/test_server_ownership.py new file mode 100644 index 00000000..6b7f133d --- /dev/null +++ b/tests/admin/test_server_ownership.py @@ -0,0 +1,267 @@ +"""Reservations and readiness must belong to the fixture's own HTTP process.""" +from contextlib import ExitStack, nullcontext +import errno +import http.server +import importlib.util +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import threading +import time +from types import SimpleNamespace + +import pytest +import conftest + + +@pytest.fixture +def harness(): + source = os.environ.get("ZIGBASE_TEST_HARNESS_SOURCE") + if not source: + return conftest + spec = importlib.util.spec_from_file_location("harness_under_test", source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def foreign_health(): + class Handler(http.server.BaseHTTPRequestHandler): + calls = 0 + before_response = None + def do_GET(self): + Handler.calls += 1 + if Handler.before_response: + Handler.before_response() + body = b'{"status":"ok"}' + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + pass + class Server(http.server.ThreadingHTTPServer): + allow_reuse_address = True + server = Server(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_port, Handler + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def delayed_child(tmp_path, port, mode, prefix=b""): + log_path = tmp_path / "child.log" + ready_path = tmp_path / "child.ready" + program = """ +import os, pathlib, sys, time +port, ready, mode = sys.argv[1:] +if mode == 'bind_failure': + print('error: cannot bind 127.0.0.1:' + port + ': ListenError', flush=True) +else: + print('zigbase listening on http://127.0.0.1:' + port, flush=True) + print('* Root pid: ' + str(os.getpid() if mode == 'own_marker' else 1), flush=True) +pathlib.Path(ready).write_text('ready') +time.sleep(20) +""" + with log_path.open("wb") as log: + log.write(prefix) + log.flush() + child = subprocess.Popen([sys.executable, "-u", "-c", program, str(port), str(ready_path), mode], stdout=log, stderr=subprocess.STDOUT) + deadline = time.monotonic() + 5 + while not ready_path.exists() and time.monotonic() < deadline: + time.sleep(0.01) + assert ready_path.exists(), log_path.read_text() + return child, log_path + + +def test_rejects_foreign_health_after_own_bind_failure(harness, foreign_health, tmp_path): + port, handler = foreign_health + child, log_path = delayed_child(tmp_path, port, "bind_failure") + try: + # The child is deliberately alive after logging ListenError. Checking + # poll() only before the healthy foreign response is not sufficient. + assert child.poll() is None + with pytest.raises(AssertionError, match="ListenError"): + harness._wait_reachable_or_fail(child, port, log_path, timeout_s=0.4) + assert child.poll() is not None + assert handler.calls == 0 + finally: + conftest._stop_server(child) + + +def test_previous_process_startup_log_cannot_establish_readiness(harness, foreign_health, tmp_path): + port, handler = foreign_health + child, log_path = delayed_child(tmp_path, port, "previous_marker") + try: + with pytest.raises(AssertionError): + harness._wait_reachable_or_fail(child, port, log_path, timeout_s=0.3) + assert handler.calls == 0 + finally: + conftest._stop_server(child) + + +def test_process_must_still_be_alive_after_health_response(harness, foreign_health, tmp_path): + port, handler = foreign_health + child, log_path = delayed_child(tmp_path, port, "own_marker") + handler.before_response = lambda: conftest._stop_server(child) + try: + with pytest.raises(AssertionError): + harness._wait_reachable_or_fail(child, port, log_path, timeout_s=0.4) + # The health response must have been requested, or this proves nothing. + assert handler.calls >= 1 + assert child.poll() is not None + finally: + conftest._stop_server(child) + + +def test_reservations_exclude_competing_allocations_and_release_on_exception(harness): + # The same probe runs against the old check-close-bind helper for attribution. + reserve = harness._reserve_port if hasattr(harness, "_reserve_port") else lambda: nullcontext(harness._free_port()) + ports = [] + with ExitStack() as stack: + for _ in range(32): + port = stack.enter_context(reserve()) + assert port not in ports + ports.append(port) + with socket.socket() as competing: + with pytest.raises(OSError) as error: + competing.bind(("127.0.0.1", ports[0])) + assert error.value.errno == errno.EADDRINUSE + with socket.socket() as released: + released.bind(("127.0.0.1", ports[0])) + with pytest.raises(RuntimeError): + with reserve() as failed_port: + raise RuntimeError("injected startup failure") + with socket.socket() as released: + released.bind(("127.0.0.1", failed_port)) + + +@pytest.mark.parametrize("log_format,log_level", [("text", "info"), ("json", "info"), ("text", "error"), ("json", "error")]) +def test_real_facil_listener_binds_while_reservation_is_held(binary, tmp_path, log_format, log_level): + data = tmp_path / "data" + shutil.copytree(conftest._su_template_for(binary), data) + log_path = tmp_path / "actual.log" + with conftest._reserve_port() as port: + env = {**os.environ, "ZIGBASE_DATA_DIR": str(data), "ZIGBASE_HTTP_PORT": str(port), "ZIGBASE_SERVE_BACKGROUND": "0"} + # Restart logs may contain a previous process's bind error and banner. + log_path.write_text(f"cannot bind 127.0.0.1:{port}: ListenError\n* Root pid: 1\n") + log_start = log_path.stat().st_size + with log_path.open("ab") as log: + child = subprocess.Popen([binary, "serve", "--insecure-cookies", "--log-format", log_format, "--log-level", log_level], env=env, stdout=log, stderr=subprocess.STDOUT) + try: + conftest._wait_reachable_or_fail(child, port, log_path, log_start=log_start) + assert child.poll() is None + finally: + conftest._stop_server(child) + + +@pytest.mark.parametrize("fixture", ["server", "auth2_server"]) +@pytest.mark.parametrize("failure", ["spawn", "readiness"]) +def test_shared_fixtures_release_child_directory_and_lease_on_failure(monkeypatch, tmp_path, fixture, failure): + seed = tmp_path / "seed" + seed.mkdir() + monkeypatch.setattr(conftest, "_su_template_for", lambda _: str(seed)) + captured = {} + original_popen = subprocess.Popen + def launch(*args, **kwargs): + captured['data'] = kwargs['env']['ZIGBASE_DATA_DIR'] + captured['port'] = int(kwargs['env']['ZIGBASE_HTTP_PORT']) + if failure == "spawn": + raise OSError("injected spawn failure") + child = original_popen([sys.executable, "-c", "import time; time.sleep(20)"], stdout=kwargs['stdout'], stderr=kwargs['stderr']) + captured['child'] = child + return child + monkeypatch.setattr(conftest.subprocess, "Popen", launch) + def fail_readiness(*args, **kwargs): + raise AssertionError("injected readiness failure") + monkeypatch.setattr(conftest, "_wait_reachable_or_fail", fail_readiness) + if fixture == "server": + request = SimpleNamespace(param=None, node=SimpleNamespace(nodeid="ownership-probe")) + generator = conftest.server.__wrapped__("unused-binary", request) + else: + generator = conftest.auth2_server.__wrapped__("unused-binary") + try: + with pytest.raises((OSError, AssertionError), match="injected"): + next(generator) + assert not Path(captured['data']).exists() + if 'child' in captured: + assert captured['child'].poll() is not None + with socket.socket() as released: + released.bind(("127.0.0.1", captured['port'])) + finally: + generator.close() + if 'child' in captured: + conftest._stop_server(captured['child']) + + +def test_restart_readiness_reads_only_new_log_bytes(harness, foreign_health, tmp_path, monkeypatch): + port, handler = foreign_health + prefix = b"previous launch output\n" * 65536 + child, log_path = delayed_child(tmp_path, port, "own_marker", prefix) + original_open = Path.open + observed = [] + class CountReads: + def __init__(self, stream): + self.stream = stream + def __enter__(self): + self.stream.__enter__() + return self + def __exit__(self, *args): + return self.stream.__exit__(*args) + def __getattr__(self, name): + return getattr(self.stream, name) + def read(self, *args): + data = self.stream.read(*args) + observed.append(len(data)) + return data + def observe_open(path, *args, **kwargs): + stream = original_open(path, *args, **kwargs) + return CountReads(stream) if path == log_path else stream + monkeypatch.setattr(Path, "open", observe_open) + try: + harness._wait_reachable_or_fail(child, port, log_path, timeout_s=0.4, log_start=len(prefix)) + assert handler.calls == 1 + assert 0 < sum(observed) < 16384 + finally: + conftest._stop_server(child) + + +def test_unsubscribe_fixture_never_yields_an_unready_server(monkeypatch): + import test_mail_unsubscribe as unsubscribe + captured = {} + original_popen = subprocess.Popen + monkeypatch.setattr(unsubscribe, "resolve_binary", lambda *args: "unused-binary") + monkeypatch.setattr(unsubscribe.subprocess, "run", lambda *args, **kwargs: None) + def launch(*args, **kwargs): + captured['data'] = kwargs['env']['ZIGBASE_DATA_DIR'] + captured['port'] = int(kwargs['env']['ZIGBASE_HTTP_PORT']) + child = original_popen([sys.executable, "-u", "-c", "import time; print('startup intentionally delayed', flush=True); time.sleep(20)"], + stdout=kwargs['stdout'], stderr=kwargs['stderr']) + captured['child'] = child + return child + monkeypatch.setattr(unsubscribe.subprocess, "Popen", launch) + monkeypatch.setattr(time, "sleep", lambda _: None) + original_wait = conftest._wait_reachable_or_fail + def wait_briefly(proc, port, log_path, **kwargs): + return original_wait(proc, port, log_path, timeout_s=0.05) + monkeypatch.setattr(unsubscribe, "_wait_reachable_or_fail", wait_briefly, raising=False) + generator = unsubscribe.unsub_server.__wrapped__() + try: + with pytest.raises(AssertionError, match="never became reachable"): + next(generator) + assert captured['child'].poll() is not None + assert not Path(captured['data']).exists() + with socket.socket() as released: + released.bind(("127.0.0.1", captured['port'])) + finally: + generator.close() + if 'child' in captured: + conftest._stop_server(captured['child']) diff --git a/tests/admin/test_state.py b/tests/admin/test_state.py index c21506f7..751adf6c 100644 --- a/tests/admin/test_state.py +++ b/tests/admin/test_state.py @@ -10,6 +10,7 @@ - the `dating-server` fixture binary (declares two flags + one experiment) — named flags/experiment resolve, deterministically, and reflect a superuser override. """ +from conftest import _reserve_port import json import os @@ -30,12 +31,6 @@ ZIG = ["mise", "exec", "zig@0.16.0", "--", "zig"] -def _free_port(): - s = socket.socket() - s.bind(("127.0.0.1", 0)) - p = s.getsockname()[1] - s.close() - return p def _wait_port(port): @@ -172,231 +167,231 @@ def test_feature_head_is_bodyless_for_default_remapped_and_disabled( ("disabled", "/api/state?subject=head", 200), ): with tempfile.TemporaryDirectory(prefix=f"zb_feature_{mode}_") as data: - port = _free_port() - proc = subprocess.Popen( - [ - str(feature_route_binaries[mode]), - "serve", - "--http-port", - str(port), - "--data-dir", - data, - ], - env={ - **os.environ, - "ZIGBASE_SERVE_BACKGROUND": "0", - "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", - }, - ) - try: - _wait_port(port) - content_type = ( - "text/plain" - if path.startswith("/api/state") - else "application/json" - ) - _assert_head_parity( - f"http://127.0.0.1:{port}{path}", expected_status, content_type + with _reserve_port() as port: + proc = subprocess.Popen( + [ + str(feature_route_binaries[mode]), + "serve", + "--http-port", + str(port), + "--data-dir", + data, + ], + env={ + **os.environ, + "ZIGBASE_SERVE_BACKGROUND": "0", + "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", + }, ) + try: + _wait_port(port) + content_type = ( + "text/plain" + if path.startswith("/api/state") + else "application/json" + ) + _assert_head_parity( + f"http://127.0.0.1:{port}{path}", expected_status, content_type + ) - if path.startswith("/api/state"): - status, body = _http("GET", f"http://127.0.0.1:{port}{path}") - assert status == 200 - assert body == "consumer-state" - - if mode == "remapped": - for custom_path, custom_status, representation_length in ( - ( - "/custom/head-success", - 200, - len(b"custom-head-representation"), - ), - ("/custom/head-error", 500, None), - ): - status, headers, wire_body = _raw_head( - f"http://127.0.0.1:{port}{custom_path}" - ) - assert status == custom_status - assert wire_body == b"" - assert int(headers["content-length"]) > 0 - if representation_length is not None: - assert ( - int(headers["content-length"]) == representation_length + if path.startswith("/api/state"): + status, body = _http("GET", f"http://127.0.0.1:{port}{path}") + assert status == 200 + assert body == "consumer-state" + + if mode == "remapped": + for custom_path, custom_status, representation_length in ( + ( + "/custom/head-success", + 200, + len(b"custom-head-representation"), + ), + ("/custom/head-error", 500, None), + ): + status, headers, wire_body = _raw_head( + f"http://127.0.0.1:{port}{custom_path}" ) + assert status == custom_status + assert wire_body == b"" + assert int(headers["content-length"]) > 0 + if representation_length is not None: + assert ( + int(headers["content-length"]) == representation_length + ) - status, headers, wire_body = _raw_head( - f"http://127.0.0.1:{port}/custom/no-content" - ) - assert status == 204 - assert "content-length" not in headers - assert "transfer-encoding" not in headers - assert wire_body == b"" - - for method in ("HEAD", "POST"): - _, header_items, _ = _raw_response( - method, - f"http://127.0.0.1:{port}/custom/no-content", + status, headers, wire_body = _raw_head( + f"http://127.0.0.1:{port}/custom/no-content" ) - dates = [ - value for name, value in header_items if name == "date" - ] - assert dates == ["Thu, 01 Jan 1970 00:00:00 GMT"] - - status, headers, wire_body = _raw_head( - f"http://127.0.0.1:{port}/custom/not-modified" - ) - assert status == 304 - assert headers.get("content-length") == "9" - assert "transfer-encoding" not in headers - assert wire_body == b"" + assert status == 204 + assert "content-length" not in headers + assert "transfer-encoding" not in headers + assert wire_body == b"" - status, headers, wire_body = _raw_request( - "POST", f"http://127.0.0.1:{port}/custom/no-content" - ) - assert status == 204 - assert "content-length" not in headers - assert "transfer-encoding" not in headers - assert wire_body == b"" + for method in ("HEAD", "POST"): + _, header_items, _ = _raw_response( + method, + f"http://127.0.0.1:{port}/custom/no-content", + ) + dates = [ + value for name, value in header_items if name == "date" + ] + assert dates == ["Thu, 01 Jan 1970 00:00:00 GMT"] - for method in ("HEAD", "POST"): - status, headers, wire_body = _raw_request( - method, - f"http://127.0.0.1:{port}/custom/reset-content", + status, headers, wire_body = _raw_head( + f"http://127.0.0.1:{port}/custom/not-modified" ) - assert status == 205 - assert headers.get("content-length") == "0" + assert status == 304 + assert headers.get("content-length") == "9" assert "transfer-encoding" not in headers assert wire_body == b"" - for method in ("GET", "POST"): status, headers, wire_body = _raw_request( - method, - f"http://127.0.0.1:{port}/custom/wrong-framing", + "POST", f"http://127.0.0.1:{port}/custom/no-content" ) - assert status == 200 - assert headers.get("content-length") == "6" + assert status == 204 + assert "content-length" not in headers assert "transfer-encoding" not in headers - assert "trailer" not in headers - assert wire_body == b"framed" + assert wire_body == b"" - status, headers, wire_body = _raw_head( - f"http://127.0.0.1:{port}/custom/owned-file" - ) - assert status == 200 - assert headers.get("content-length") == "7" - assert wire_body == b"" - - missing_body_length = None - for method in ("GET", "HEAD"): - status, header_items, wire_body = _raw_response( - method, - f"http://127.0.0.1:{port}/custom/missing-owned-file", - ) - headers = dict(header_items) - assert status == 404 - if method == "GET": - missing_body_length = len(wire_body) - assert json.loads(wire_body)["code"] == "not_found" - else: + for method in ("HEAD", "POST"): + status, headers, wire_body = _raw_request( + method, + f"http://127.0.0.1:{port}/custom/reset-content", + ) + assert status == 205 + assert headers.get("content-length") == "0" + assert "transfer-encoding" not in headers assert wire_body == b"" - assert int(headers["content-length"]) == missing_body_length - header_names = [name for name, _ in header_items] - assert header_names.count("content-length") == 1 - assert header_names.count("content-type") == 1 - assert not { - "accept-ranges", - "content-range", - "etag", - }.intersection(header_names) - - for method, path in ( - ("GET", "/custom/early-hints"), - ("HEAD", "/custom/early-hints"), - ("GET", "/custom/informational-unnamed"), - ("GET", "/custom/status-unsupported"), - ("POST", "/custom/not-modified"), - ): - status, header_items, wire_body = _raw_response( - method, f"http://127.0.0.1:{port}{path}" + + for method in ("GET", "POST"): + status, headers, wire_body = _raw_request( + method, + f"http://127.0.0.1:{port}/custom/wrong-framing", + ) + assert status == 200 + assert headers.get("content-length") == "6" + assert "transfer-encoding" not in headers + assert "trailer" not in headers + assert wire_body == b"framed" + + status, headers, wire_body = _raw_head( + f"http://127.0.0.1:{port}/custom/owned-file" ) - headers = dict(header_items) - assert status == 500 - assert headers["content-type"].startswith("application/json") - assert int(headers["content-length"]) > 0 - assert "transfer-encoding" not in headers - if method == "HEAD": - assert wire_body == b"" - else: - assert json.loads(wire_body)["code"] == "internal" - if path == "/custom/status-unsupported": - assert any( - value.startswith("invalid-status=kept") - for name, value in header_items - if name == "set-cookie" + assert status == 200 + assert headers.get("content-length") == "7" + assert wire_body == b"" + + missing_body_length = None + for method in ("GET", "HEAD"): + status, header_items, wire_body = _raw_response( + method, + f"http://127.0.0.1:{port}/custom/missing-owned-file", + ) + headers = dict(header_items) + assert status == 404 + if method == "GET": + missing_body_length = len(wire_body) + assert json.loads(wire_body)["code"] == "not_found" + else: + assert wire_body == b"" + assert int(headers["content-length"]) == missing_body_length + header_names = [name for name, _ in header_items] + assert header_names.count("content-length") == 1 + assert header_names.count("content-type") == 1 + assert not { + "accept-ranges", + "content-range", + "etag", + }.intersection(header_names) + + for method, path in ( + ("GET", "/custom/early-hints"), + ("HEAD", "/custom/early-hints"), + ("GET", "/custom/informational-unnamed"), + ("GET", "/custom/status-unsupported"), + ("POST", "/custom/not-modified"), + ): + status, header_items, wire_body = _raw_response( + method, f"http://127.0.0.1:{port}{path}" ) - finally: - proc.terminate() - proc.wait(timeout=10) + headers = dict(header_items) + assert status == 500 + assert headers["content-type"].startswith("application/json") + assert int(headers["content-length"]) > 0 + assert "transfer-encoding" not in headers + if method == "HEAD": + assert wire_body == b"" + else: + assert json.loads(wire_body)["code"] == "internal" + if path == "/custom/status-unsupported": + assert any( + value.startswith("invalid-status=kept") + for name, value in header_items + if name == "set-cookie" + ) + finally: + proc.terminate() + proc.wait(timeout=10) def test_invalid_handler_status_logs_the_normalized_wire_status(feature_route_binaries): with tempfile.TemporaryDirectory(prefix="zb_invalid_status_") as data: - port = _free_port() - log_path = pathlib.Path(data) / "server.log" - with log_path.open("w") as log: - proc = subprocess.Popen( - [ - str(feature_route_binaries["remapped"]), - "serve", - "--http-port", - str(port), - "--data-dir", - data, - "--log-format", - "json", - ], - env={ - **os.environ, - "ZIGBASE_SERVE_BACKGROUND": "0", - "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", - }, - stdout=log, - stderr=subprocess.STDOUT, + with _reserve_port() as port: + log_path = pathlib.Path(data) / "server.log" + with log_path.open("w") as log: + proc = subprocess.Popen( + [ + str(feature_route_binaries["remapped"]), + "serve", + "--http-port", + str(port), + "--data-dir", + data, + "--log-format", + "json", + ], + env={ + **os.environ, + "ZIGBASE_SERVE_BACKGROUND": "0", + "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", + }, + stdout=log, + stderr=subprocess.STDOUT, + ) + try: + _wait_port(port) + long_path = "/custom/status-unsupported/" + "x" * 256 + for path in ("/custom/status-unsupported", long_path): + status, body = _http( + "GET", f"http://127.0.0.1:{port}{path}" + ) + assert status == 500 + assert json.loads(body)["code"] == "internal" + time.sleep(0.2) + finally: + proc.terminate() + proc.wait(timeout=10) + + log_text = log_path.read_text() + records = [ + json.loads(line) + for line in log_text.splitlines() + if line.strip().startswith("{") + ] + request = next( + record + for record in records + if record.get("msg") == "request" + and record.get("path") == "/custom/status-unsupported" ) - try: - _wait_port(port) - long_path = "/custom/status-unsupported/" + "x" * 256 - for path in ("/custom/status-unsupported", long_path): - status, body = _http( - "GET", f"http://127.0.0.1:{port}{path}" - ) - assert status == 500 - assert json.loads(body)["code"] == "internal" - time.sleep(0.2) - finally: - proc.terminate() - proc.wait(timeout=10) - - log_text = log_path.read_text() - records = [ - json.loads(line) - for line in log_text.splitlines() - if line.strip().startswith("{") - ] - request = next( - record - for record in records - if record.get("msg") == "request" - and record.get("path") == "/custom/status-unsupported" - ) - assert request["status"] == 500 - assert ( - "route returned invalid final status 799 for GET " - "/custom/status-unsupported" in log_text - ) - long_message = f"route returned invalid final status 799 for GET {long_path}" - assert long_message in log_text - assert f"fixture observed InvalidResponseStatus: {long_message}" in log_text + assert request["status"] == 500 + assert ( + "route returned invalid final status 799 for GET " + "/custom/status-unsupported" in log_text + ) + long_message = f"route returned invalid final status 799 for GET {long_path}" + assert long_message in log_text + assert f"fixture observed InvalidResponseStatus: {long_message}" in log_text # --------------------------------------------------------------------------- @@ -426,21 +421,21 @@ def dating_server(dating_binary): ], check=True, ) - port = _free_port() - env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_HTTP_PORT": str(port)} - proc = subprocess.Popen( - [dating_binary, "serve", "--insecure-cookies"], - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - _wait_port(port) - try: - yield f"http://127.0.0.1:{port}" - finally: - proc.terminate() - proc.wait(timeout=5) - shutil.rmtree(data, ignore_errors=True) + with _reserve_port() as port: + env = {**os.environ, "ZIGBASE_DATA_DIR": data, "ZIGBASE_HTTP_PORT": str(port)} + proc = subprocess.Popen( + [dating_binary, "serve", "--insecure-cookies"], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + _wait_port(port) + try: + yield f"http://127.0.0.1:{port}" + finally: + proc.terminate() + proc.wait(timeout=5) + shutil.rmtree(data, ignore_errors=True) def _su_token(base): diff --git a/tests/admin/test_static_files.py b/tests/admin/test_static_files.py index 76758223..671f156f 100644 --- a/tests/admin/test_static_files.py +++ b/tests/admin/test_static_files.py @@ -1,3 +1,4 @@ +from conftest import _reserve_port import socket, subprocess, tempfile, time, os, pathlib, shutil, urllib.request, urllib.error import pytest @@ -7,8 +8,6 @@ ZIG = ["mise", "exec", "zig@0.16.0", "--", "zig"] -def _free_port(): - s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p def _wait_up(url, deadline_s=20): @@ -49,77 +48,77 @@ def test_serve_static_runtime_mode(): (pub / "app.wasm").write_bytes(b"wasm") (pub / "assets").mkdir() (pub / "assets" / "app.js").write_text("console.log('hi')") - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", static], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - - # index.html at / - st, hdr, body = _get(f"{base}/") - assert st == 200 and b"hello static" in body - for name, content_type in ( - ("manifest.webmanifest", "application/manifest+json"), - ("image.avif", "image/avif"), - ("app.wasm", "application/wasm"), - ): - status, headers, _ = _get(f"{base}/{name}") - assert status == 200 - assert _hdr(headers, "Content-Type") == content_type - assert _hdr(headers, "X-Content-Type-Options") == "nosniff" - assert "text/html" in _hdr(hdr, "content-type") - - # asset with content type + ETag, then 304 - st, hdr, body = _get(f"{base}/assets/app.js") - assert st == 200 and b"console.log" in body - # dir mode delegates caching to facil.io's sendFile: one (unquoted, - # base64) etag header, If-None-Match/304 answered by the transport - etag_values = hdr.get_all("etag") or [] - assert len(etag_values) == 1, f"expected exactly one etag header, got: {etag_values}" - etag = etag_values[0] - st, _, _ = _get(f"{base}/assets/app.js", {"If-None-Match": etag}) - assert st == 304 - - # static miss -> plain 404 (not the JSON envelope) - st, hdr, _ = _get(f"{base}/missing.css") - assert st == 404 - assert "application/json" not in _hdr(hdr, "content-type") - - # /api/ miss keeps the JSON envelope - st, hdr, body = _get(f"{base}/api/definitely-missing") - assert st == 404 - assert "application/json" in _hdr(hdr, "content-type") - - # bare /api (no trailing slash) also stays in the API namespace - st, hdr, _ = _get(f"{base}/api") - assert st == 404 - assert "application/json" in _hdr(hdr, "content-type") - - # traversal blocked - st, _, _ = _get(f"{base}/..%2f..%2fetc%2fpasswd") - assert st in (400, 404) - - # admin UI still wins over static - st, hdr, _ = _get(f"{base}/_/") - assert st == 200 and "text/html" in _hdr(hdr, "content-type") - finally: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", static], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + + # index.html at / + st, hdr, body = _get(f"{base}/") + assert st == 200 and b"hello static" in body + for name, content_type in ( + ("manifest.webmanifest", "application/manifest+json"), + ("image.avif", "image/avif"), + ("app.wasm", "application/wasm"), + ): + status, headers, _ = _get(f"{base}/{name}") + assert status == 200 + assert _hdr(headers, "Content-Type") == content_type + assert _hdr(headers, "X-Content-Type-Options") == "nosniff" + assert "text/html" in _hdr(hdr, "content-type") + + # asset with content type + ETag, then 304 + st, hdr, body = _get(f"{base}/assets/app.js") + assert st == 200 and b"console.log" in body + # dir mode delegates caching to facil.io's sendFile: one (unquoted, + # base64) etag header, If-None-Match/304 answered by the transport + etag_values = hdr.get_all("etag") or [] + assert len(etag_values) == 1, f"expected exactly one etag header, got: {etag_values}" + etag = etag_values[0] + st, _, _ = _get(f"{base}/assets/app.js", {"If-None-Match": etag}) + assert st == 304 + + # static miss -> plain 404 (not the JSON envelope) + st, hdr, _ = _get(f"{base}/missing.css") + assert st == 404 + assert "application/json" not in _hdr(hdr, "content-type") + + # /api/ miss keeps the JSON envelope + st, hdr, body = _get(f"{base}/api/definitely-missing") + assert st == 404 + assert "application/json" in _hdr(hdr, "content-type") + + # bare /api (no trailing slash) also stays in the API namespace + st, hdr, _ = _get(f"{base}/api") + assert st == 404 + assert "application/json" in _hdr(hdr, "content-type") + + # traversal blocked + st, _, _ = _get(f"{base}/..%2f..%2fetc%2fpasswd") + assert st in (400, 404) + + # admin UI still wins over static + st, hdr, _ = _get(f"{base}/_/") + assert st == 200 and "text/html" in _hdr(hdr, "content-type") + finally: + proc.terminate(); proc.wait(timeout=10) def test_serve_static_missing_dir_is_fatal(): binary = resolve_binary("ZIGBASE_TEST_BINARY", REPO, "zigbase") with tempfile.TemporaryDirectory() as data: - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", "/nonexistent/static/dir"], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - assert proc.wait(timeout=20) != 0 + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", "/nonexistent/static/dir"], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + assert proc.wait(timeout=20) != 0 def test_embedded_static_in_plugins_example(): @@ -127,28 +126,28 @@ def test_embedded_static_in_plugins_example(): if binary is None: pytest.skip("npm not available and no prebuilt plugins binary; cannot build the plugins frontend") with tempfile.TemporaryDirectory() as data: - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data], - # authors.private_notes is an .encrypted field -> serve requires ZIGBASE_FIELD_KEY. - env={ - **os.environ, - "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", - "ZIGBASE_FIELD_KEY": "plugins-static-test-field-key", - }, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - st, hdr, body = _get(f"{base}/") - assert st == 200 and b"one binary" in body.lower() - assert "text/html" in _hdr(hdr, "Content-Type") - etag = _hdr(hdr, "ETag") - assert etag and etag.startswith('"') - st, _, _ = _get(f"{base}/", {"If-None-Match": etag}) - assert st == 304 - finally: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data], + # authors.private_notes is an .encrypted field -> serve requires ZIGBASE_FIELD_KEY. + env={ + **os.environ, + "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", + "ZIGBASE_FIELD_KEY": "plugins-static-test-field-key", + }, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + st, hdr, body = _get(f"{base}/") + assert st == 200 and b"one binary" in body.lower() + assert "text/html" in _hdr(hdr, "Content-Type") + etag = _hdr(hdr, "ETag") + assert etag and etag.startswith('"') + st, _, _ = _get(f"{base}/", {"If-None-Match": etag}) + assert st == 304 + finally: + proc.terminate(); proc.wait(timeout=10) def test_spa_marker_fallback(): @@ -165,50 +164,50 @@ def test_spa_marker_fallback(): (pub / "app" / ".spa").write_text("MARKER-SECRET") # contents are ignored (presence-only) (pub / "app" / "assets").mkdir() (pub / "app" / "assets" / "app.js").write_text("console.log('real asset')") - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", static], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - - # AC1: deep link under the marked dir -> the app shell, 200 text/html - st, hdr, body = _get(f"{base}/app/orders/1234") - assert st == 200 and b"app shell" in body - assert "text/html" in _hdr(hdr, "content-type") - - # AC1: real files under the marked dir serve directly - st, _, body = _get(f"{base}/app/assets/app.js") - assert st == 200 and b"real asset" in body - - # an extension-bearing MISS under the root still gets the shell (200 HTML) - st, hdr, body = _get(f"{base}/app/assets/gone.js") - assert st == 200 and b"app shell" in body - - # AC1: / serves the real root index; misses OUTSIDE /app/ still 404 - st, _, body = _get(f"{base}/") - assert st == 200 and b"site home" in body - st, hdr, _ = _get(f"{base}/pricing") - assert st == 404 - assert "application/json" not in _hdr(hdr, "content-type") - # '/'-bounded: /application is outside the app/ subtree - st, _, _ = _get(f"{base}/application") - assert st == 404 - - # the marker file itself is never served (falls through to the shell) - st, _, body = _get(f"{base}/app/.spa") - assert b"MARKER-SECRET" not in body - assert st == 200 and b"app shell" in body - - # AC4: unknown /api paths keep the JSON 404 envelope - st, hdr, _ = _get(f"{base}/api/definitely-missing") - assert st == 404 - assert "application/json" in _hdr(hdr, "content-type") - finally: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", static], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + + # AC1: deep link under the marked dir -> the app shell, 200 text/html + st, hdr, body = _get(f"{base}/app/orders/1234") + assert st == 200 and b"app shell" in body + assert "text/html" in _hdr(hdr, "content-type") + + # AC1: real files under the marked dir serve directly + st, _, body = _get(f"{base}/app/assets/app.js") + assert st == 200 and b"real asset" in body + + # an extension-bearing MISS under the root still gets the shell (200 HTML) + st, hdr, body = _get(f"{base}/app/assets/gone.js") + assert st == 200 and b"app shell" in body + + # AC1: / serves the real root index; misses OUTSIDE /app/ still 404 + st, _, body = _get(f"{base}/") + assert st == 200 and b"site home" in body + st, hdr, _ = _get(f"{base}/pricing") + assert st == 404 + assert "application/json" not in _hdr(hdr, "content-type") + # '/'-bounded: /application is outside the app/ subtree + st, _, _ = _get(f"{base}/application") + assert st == 404 + + # the marker file itself is never served (falls through to the shell) + st, _, body = _get(f"{base}/app/.spa") + assert b"MARKER-SECRET" not in body + assert st == 200 and b"app shell" in body + + # AC4: unknown /api paths keep the JSON 404 envelope + st, hdr, _ = _get(f"{base}/api/definitely-missing") + assert st == 404 + assert "application/json" in _hdr(hdr, "content-type") + finally: + proc.terminate(); proc.wait(timeout=10) def test_spa_marker_live_dir_mode_no_restart(): @@ -223,35 +222,35 @@ def test_spa_marker_live_dir_mode_no_restart(): (pub / "app").mkdir() (pub / "app" / "index.html").write_text("

app shell

") # deliberately NO app/.spa yet — the marker is added after boot, below - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", static], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - - # before the marker exists: a miss under app/ is a plain 404, not the shell - st, hdr, _ = _get(f"{base}/app/orders/1234") - assert st == 404 - assert "application/json" not in _hdr(hdr, "content-type") - - # simulate a post-boot deploy: drop the marker WITHOUT restarting the server - (pub / "app" / ".spa").write_text("") - - # the very next request picks it up live - st, hdr, body = _get(f"{base}/app/orders/1234") - assert st == 200 and b"app shell" in body - assert "text/html" in _hdr(hdr, "content-type") - - # removing the marker again (still live, still no restart) stops the fallback - (pub / "app" / ".spa").unlink() - st, _, _ = _get(f"{base}/app/orders/5678") - assert st == 404 - finally: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", static], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + + # before the marker exists: a miss under app/ is a plain 404, not the shell + st, hdr, _ = _get(f"{base}/app/orders/1234") + assert st == 404 + assert "application/json" not in _hdr(hdr, "content-type") + + # simulate a post-boot deploy: drop the marker WITHOUT restarting the server + (pub / "app" / ".spa").write_text("") + + # the very next request picks it up live + st, hdr, body = _get(f"{base}/app/orders/1234") + assert st == 200 and b"app shell" in body + assert "text/html" in _hdr(hdr, "content-type") + + # removing the marker again (still live, still no restart) stops the fallback + (pub / "app" / ".spa").unlink() + st, _, _ = _get(f"{base}/app/orders/5678") + assert st == 404 + finally: + proc.terminate(); proc.wait(timeout=10) def test_spa_marker_missing_index_is_fatal_at_startup(): @@ -264,22 +263,22 @@ def test_spa_marker_missing_index_is_fatal_at_startup(): (pub / "index.html").write_text("

site home

") (pub / "broken").mkdir() (pub / "broken" / ".spa").write_text("") # no broken/index.html - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", static], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - ) - try: - code = proc.wait(timeout=20) - out = proc.stdout.read().decode("utf-8", errors="replace") - assert code != 0, f"expected a non-zero exit; stdout/stderr was:\n{out}" - assert "broken" in out, f"expected the offending path 'broken' named in the error; got:\n{out}" - assert "index.html" in out, f"expected the error to mention the missing index.html; got:\n{out}" - finally: - if proc.poll() is None: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", static], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + ) + try: + code = proc.wait(timeout=20) + out = proc.stdout.read().decode("utf-8", errors="replace") + assert code != 0, f"expected a non-zero exit; stdout/stderr was:\n{out}" + assert "broken" in out, f"expected the offending path 'broken' named in the error; got:\n{out}" + assert "index.html" in out, f"expected the error to mention the missing index.html; got:\n{out}" + finally: + if proc.poll() is None: + proc.terminate(); proc.wait(timeout=10) def test_spa_marker_root(): @@ -292,38 +291,38 @@ def test_spa_marker_root(): (pub / ".spa").write_text("") (pub / "assets").mkdir() (pub / "assets" / "app.js").write_text("console.log('root asset')") - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", static], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - - # AC2: any miss -> the root shell (200) - st, hdr, body = _get(f"{base}/some/deep/client/route") - assert st == 200 and b"spa root shell" in body - assert "text/html" in _hdr(hdr, "content-type") - - # AC2: real files still win - st, _, body = _get(f"{base}/assets/app.js") - assert st == 200 and b"root asset" in body - - # AC4: the root marker must NOT swallow the API namespace - st, hdr, _ = _get(f"{base}/api/definitely-missing") - assert st == 404 - assert "application/json" in _hdr(hdr, "content-type") - st, hdr, _ = _get(f"{base}/api") - assert st == 404 - assert "application/json" in _hdr(hdr, "content-type") - - # the admin SPA still wins over the root marker - st, hdr, _ = _get(f"{base}/_/") - assert st == 200 and "text/html" in _hdr(hdr, "content-type") - finally: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", static], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + + # AC2: any miss -> the root shell (200) + st, hdr, body = _get(f"{base}/some/deep/client/route") + assert st == 200 and b"spa root shell" in body + assert "text/html" in _hdr(hdr, "content-type") + + # AC2: real files still win + st, _, body = _get(f"{base}/assets/app.js") + assert st == 200 and b"root asset" in body + + # AC4: the root marker must NOT swallow the API namespace + st, hdr, _ = _get(f"{base}/api/definitely-missing") + assert st == 404 + assert "application/json" in _hdr(hdr, "content-type") + st, hdr, _ = _get(f"{base}/api") + assert st == 404 + assert "application/json" in _hdr(hdr, "content-type") + + # the admin SPA still wins over the root marker + st, hdr, _ = _get(f"{base}/_/") + assert st == 200 and "text/html" in _hdr(hdr, "content-type") + finally: + proc.terminate(); proc.wait(timeout=10) def test_dir_static_range_seek_forms(): @@ -333,32 +332,32 @@ def test_dir_static_range_seek_forms(): with tempfile.TemporaryDirectory() as static, tempfile.TemporaryDirectory() as data: blob = bytes(range(256)) * 4 # 1024 recognizable bytes (pathlib.Path(static) / "clip.bin").write_bytes(blob) - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", static], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - url = f"{base}/clip.bin" - - st, hdr, body = _get(url, {"Range": "bytes=200-"}) # open-ended seek - assert st == 206 and body == blob[200:] - assert _hdr(hdr, "Content-Range") == "bytes 200-1023/1024" - st, _, body = _get(url, {"Range": "bytes=-100"}) # suffix - assert st == 206 and body == blob[-100:] - st, _, body = _get(url, {"Range": "bytes=100-199"}) # already canonical - assert st == 206 and body == blob[100:200] - st, hdr, _ = _get(url, {"Range": "bytes=5000-"}) # owned 416 - assert st == 416 and _hdr(hdr, "Content-Range") == "bytes */1024" - st, hdr, body = _get(url) # plain 200: Vary present, single stock Cache-Control - assert st == 200 and body == blob - assert _hdr(hdr, "Vary") == "Accept-Encoding" - assert (hdr.get_all("Cache-Control") or []) == ["max-age=3600"] - finally: - proc.terminate(); proc.wait(timeout=5) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", static], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + url = f"{base}/clip.bin" + + st, hdr, body = _get(url, {"Range": "bytes=200-"}) # open-ended seek + assert st == 206 and body == blob[200:] + assert _hdr(hdr, "Content-Range") == "bytes 200-1023/1024" + st, _, body = _get(url, {"Range": "bytes=-100"}) # suffix + assert st == 206 and body == blob[-100:] + st, _, body = _get(url, {"Range": "bytes=100-199"}) # already canonical + assert st == 206 and body == blob[100:200] + st, hdr, _ = _get(url, {"Range": "bytes=5000-"}) # owned 416 + assert st == 416 and _hdr(hdr, "Content-Range") == "bytes */1024" + st, hdr, body = _get(url) # plain 200: Vary present, single stock Cache-Control + assert st == 200 and body == blob + assert _hdr(hdr, "Vary") == "Accept-Encoding" + assert (hdr.get_all("Cache-Control") or []) == ["max-age=3600"] + finally: + proc.terminate(); proc.wait(timeout=5) def test_dir_static_if_range_resume(): @@ -373,38 +372,38 @@ def test_dir_static_if_range_resume(): with tempfile.TemporaryDirectory() as static, tempfile.TemporaryDirectory() as data: blob = bytes(range(256)) * 4 # 1024 recognizable bytes (pathlib.Path(static) / "clip.bin").write_bytes(blob) - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", static], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - url = f"{base}/clip.bin" - - # obtain facil.io's current ETag (the validator a resuming client echoes) - st, hdr, _ = _get(url) - assert st == 200 - etag = _hdr(hdr, "etag") - assert etag, "dir-mode response should carry facil.io's ETag" - - # MATCH: resume the transfer -> 206 with the requested window (the #192 fix; - # previously facil.io deleted the Range and returned a full 200) - st, hdr, body = _get(url, {"Range": "bytes=200-399", "If-Range": etag}) - assert st == 206, f"matching If-Range must resume (206), got {st}" - assert body == blob[200:400] - assert _hdr(hdr, "Content-Range") == "bytes 200-399/1024" - - # MISMATCH: stays a 206 in dir mode (documented residual — cannot distinguish - # a match from a mismatch without facil.io's non-reproducible ETag). This must - # at minimum NOT regress into the old wrong-200-on-match behavior. - st, _, body = _get(url, {"Range": "bytes=200-399", "If-Range": '"stale-etag"'}) - assert st == 206 - assert body == blob[200:400] - finally: - proc.terminate(); proc.wait(timeout=5) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", static], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + url = f"{base}/clip.bin" + + # obtain facil.io's current ETag (the validator a resuming client echoes) + st, hdr, _ = _get(url) + assert st == 200 + etag = _hdr(hdr, "etag") + assert etag, "dir-mode response should carry facil.io's ETag" + + # MATCH: resume the transfer -> 206 with the requested window (the #192 fix; + # previously facil.io deleted the Range and returned a full 200) + st, hdr, body = _get(url, {"Range": "bytes=200-399", "If-Range": etag}) + assert st == 206, f"matching If-Range must resume (206), got {st}" + assert body == blob[200:400] + assert _hdr(hdr, "Content-Range") == "bytes 200-399/1024" + + # MISMATCH: stays a 206 in dir mode (documented residual — cannot distinguish + # a match from a mismatch without facil.io's non-reproducible ETag). This must + # at minimum NOT regress into the old wrong-200-on-match behavior. + st, _, body = _get(url, {"Range": "bytes=200-399", "If-Range": '"stale-etag"'}) + assert st == 206 + assert body == blob[200:400] + finally: + proc.terminate(); proc.wait(timeout=5) def test_static_cache_control_knob_flag_env_and_default(): @@ -417,20 +416,20 @@ def _spin(extra_args, extra_env): data_dir = tempfile.mkdtemp() try: (pathlib.Path(static_dir) / "asset.txt").write_text("hello knob") - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data_dir, - "--serve-static", static_dir, *extra_args], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", **extra_env}, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - st, hdr, body = _get(f"{base}/asset.txt") - assert st == 200 and body == b"hello knob" - return hdr.get_all("Cache-Control") or [] - finally: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data_dir, + "--serve-static", static_dir, *extra_args], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", **extra_env}, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + st, hdr, body = _get(f"{base}/asset.txt") + assert st == 200 and body == b"hello knob" + return hdr.get_all("Cache-Control") or [] + finally: + proc.terminate(); proc.wait(timeout=10) finally: shutil.rmtree(static_dir, ignore_errors=True) shutil.rmtree(data_dir, ignore_errors=True) @@ -459,35 +458,35 @@ def test_embedded_static_emits_cache_control_and_range(): if binary is None: pytest.skip("npm not available and no prebuilt plugins binary; cannot build the plugins frontend") with tempfile.TemporaryDirectory() as data: - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data], - env={ - **os.environ, - "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", - "ZIGBASE_FIELD_KEY": "plugins-static-test-field-key", - }, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - - st, hdr, body = _get(f"{base}/") - assert st == 200 - assert (hdr.get_all("Cache-Control") or []) == ["max-age=3600"] - assert _hdr(hdr, "Accept-Ranges") == "bytes" - etag = _hdr(hdr, "ETag") - assert etag and etag.startswith('"') and etag.endswith('"') - assert len(etag) == 10 # `"` + 8 hex + `"` - - st, hdr, part = _get(f"{base}/", {"Range": "bytes=0-9"}) - assert st == 206 and part == body[:10] - assert _hdr(hdr, "Content-Range") == f"bytes 0-9/{len(body)}" - - st, _, _ = _get(f"{base}/", {"If-None-Match": etag}) - assert st == 304 - finally: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data], + env={ + **os.environ, + "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef", + "ZIGBASE_FIELD_KEY": "plugins-static-test-field-key", + }, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + + st, hdr, body = _get(f"{base}/") + assert st == 200 + assert (hdr.get_all("Cache-Control") or []) == ["max-age=3600"] + assert _hdr(hdr, "Accept-Ranges") == "bytes" + etag = _hdr(hdr, "ETag") + assert etag and etag.startswith('"') and etag.endswith('"') + assert len(etag) == 10 # `"` + 8 hex + `"` + + st, hdr, part = _get(f"{base}/", {"Range": "bytes=0-9"}) + assert st == 206 and part == body[:10] + assert _hdr(hdr, "Content-Range") == f"bytes 0-9/{len(body)}" + + st, _, _ = _get(f"{base}/", {"If-None-Match": etag}) + assert st == 304 + finally: + proc.terminate(); proc.wait(timeout=10) def test_spa_fallback_shell_no_cache_and_etag(): @@ -499,31 +498,31 @@ def test_spa_fallback_shell_no_cache_and_etag(): pub = pathlib.Path(static) (pub / "index.html").write_text("

spa root shell

") (pub / ".spa").write_text("") - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", static], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - - st, hdr, body = _get(f"{base}/some/deep/client/route") - assert st == 200 and b"spa root shell" in body - assert _hdr(hdr, "Cache-Control") == "no-cache" - etag = _hdr(hdr, "ETag") - assert etag and etag.startswith('"') and etag.endswith('"') - - st, _, _ = _get(f"{base}/some/deep/client/route", {"If-None-Match": etag}) - assert st == 304 - - # direct hit on the real file: delegated facil.io path, NOT no-cache - st, hdr, _ = _get(f"{base}/index.html") - assert st == 200 - assert _hdr(hdr, "Cache-Control") != "no-cache" - finally: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", static], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + + st, hdr, body = _get(f"{base}/some/deep/client/route") + assert st == 200 and b"spa root shell" in body + assert _hdr(hdr, "Cache-Control") == "no-cache" + etag = _hdr(hdr, "ETag") + assert etag and etag.startswith('"') and etag.endswith('"') + + st, _, _ = _get(f"{base}/some/deep/client/route", {"If-None-Match": etag}) + assert st == 304 + + # direct hit on the real file: delegated facil.io path, NOT no-cache + st, hdr, _ = _get(f"{base}/index.html") + assert st == 200 + assert _hdr(hdr, "Cache-Control") != "no-cache" + finally: + proc.terminate(); proc.wait(timeout=10) def test_gz_sidecar_still_served_with_vary(): @@ -536,27 +535,27 @@ def test_gz_sidecar_still_served_with_vary(): pub = pathlib.Path(static) (pub / "big.txt").write_text("plain " * 100) (pub / "big.txt.gz").write_bytes(b"\x1f\x8b" + b"z" * 50) # sidecar bytes, content irrelevant - port = _free_port() - proc = subprocess.Popen( - [str(binary), "serve", "--http-port", str(port), "--data-dir", data, - "--serve-static", static], - env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, - ) - try: - base = f"http://127.0.0.1:{port}" - _wait_up(f"{base}/api/health") - url = f"{base}/big.txt" - - st, hdr, body = _get(url, {"Accept-Encoding": "gzip"}) - assert st == 200 - assert _hdr(hdr, "Content-Encoding") == "gzip" - assert _hdr(hdr, "Vary") == "Accept-Encoding" - assert body == b"\x1f\x8b" + b"z" * 50 - - # no Accept-Encoding: the base file, no Content-Encoding - st, hdr, body = _get(url) - assert st == 200 - assert _hdr(hdr, "Content-Encoding") == "" - assert body == (b"plain " * 100) - finally: - proc.terminate(); proc.wait(timeout=10) + with _reserve_port() as port: + proc = subprocess.Popen( + [str(binary), "serve", "--http-port", str(port), "--data-dir", data, + "--serve-static", static], + env={**os.environ, "ZIGBASE_JWT_SECRET": "test-secret-not-default-0123456789abcdef"}, + ) + try: + base = f"http://127.0.0.1:{port}" + _wait_up(f"{base}/api/health") + url = f"{base}/big.txt" + + st, hdr, body = _get(url, {"Accept-Encoding": "gzip"}) + assert st == 200 + assert _hdr(hdr, "Content-Encoding") == "gzip" + assert _hdr(hdr, "Vary") == "Accept-Encoding" + assert body == b"\x1f\x8b" + b"z" * 50 + + # no Accept-Encoding: the base file, no Content-Encoding + st, hdr, body = _get(url) + assert st == 200 + assert _hdr(hdr, "Content-Encoding") == "" + assert body == (b"plain " * 100) + finally: + proc.terminate(); proc.wait(timeout=10) diff --git a/tests/admin/test_superuser_cli.py b/tests/admin/test_superuser_cli.py index 4bb9476d..a0ca2450 100644 --- a/tests/admin/test_superuser_cli.py +++ b/tests/admin/test_superuser_cli.py @@ -9,7 +9,7 @@ import pytest -from conftest import _free_port, _stop_server, _wait_reachable_or_fail +from conftest import _reserve_port, _stop_server, _wait_reachable_or_fail @pytest.mark.parametrize("backend", ["sqlite", "postgres"]) @@ -39,32 +39,32 @@ def create(value): for secret in (password, "replacement-password"): assert secret not in first.stdout + first.stderr + duplicate.stdout + duplicate.stderr - port = _free_port() - env["ZIGBASE_HTTP_PORT"] = str(port) - log_path = tmp_path / "server.log" - with log_path.open("wb") as log: - proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, - stdout=log, stderr=subprocess.STDOUT) - try: - _wait_reachable_or_fail(proc, port, str(log_path)) + with _reserve_port() as port: + env["ZIGBASE_HTTP_PORT"] = str(port) + log_path = tmp_path / "server.log" + with log_path.open("wb") as log: + proc = subprocess.Popen([binary, "serve", "--insecure-cookies"], env=env, + stdout=log, stderr=subprocess.STDOUT) + try: + _wait_reachable_or_fail(proc, port, str(log_path)) - def login(value): - request = urllib.request.Request( - f"http://127.0.0.1:{port}/api/collections/_superusers/auth-with-password", - data=json.dumps({"identity": email, "password": value}).encode(), - headers={"Content-Type": "application/json"}, method="POST") - try: - response = urllib.request.urlopen(request, timeout=10) - except urllib.error.HTTPError as error: - response = error - with response: - return response.status, json.load(response) + def login(value): + request = urllib.request.Request( + f"http://127.0.0.1:{port}/api/collections/_superusers/auth-with-password", + data=json.dumps({"identity": email, "password": value}).encode(), + headers={"Content-Type": "application/json"}, method="POST") + try: + response = urllib.request.urlopen(request, timeout=10) + except urllib.error.HTTPError as error: + response = error + with response: + return response.status, json.load(response) - code, result = login(password) - assert code == 200, result - assert result["token"] and result["record"]["email"] == email - for name in ("created", "updated"): - assert datetime.datetime.fromisoformat(result["record"][name]) - assert login("replacement-password")[0] == 400 - finally: - _stop_server(proc) + code, result = login(password) + assert code == 200, result + assert result["token"] and result["record"]["email"] == email + for name in ("created", "updated"): + assert datetime.datetime.fromisoformat(result["record"][name]) + assert login("replacement-password")[0] == 400 + finally: + _stop_server(proc) diff --git a/tests/admin/test_thumbnails.py b/tests/admin/test_thumbnails.py index e268f850..5797f40a 100644 --- a/tests/admin/test_thumbnails.py +++ b/tests/admin/test_thumbnails.py @@ -12,7 +12,7 @@ import zlib import pytest -from conftest import _free_port, _su_template_for, _wait_reachable_or_fail +from conftest import _reserve_port, _su_template_for, _wait_reachable_or_fail from test_file_range import _multipart @@ -45,18 +45,18 @@ def thumbnail_server(tmp_path): pytest.skip("requires thumbnails-fixture built with -Dimage-thumbnails=true") data = tmp_path / "data" shutil.copytree(_su_template_for(binary), data) - port = _free_port() - env = {**os.environ, "ZIGBASE_SERVE_BACKGROUND": "false"} - log = (tmp_path / "server.log").open("w+") - process = subprocess.Popen([binary, "serve", "--insecure-cookies", "--http-port", str(port), "--data-dir", str(data)], env=env, stdout=log, stderr=log) - base = f"http://127.0.0.1:{port}" - try: - _wait_reachable_or_fail(process, port, tmp_path / "server.log") - yield base, data - finally: - process.terminate() - process.wait(timeout=10) - log.close() + with _reserve_port() as port: + env = {**os.environ, "ZIGBASE_SERVE_BACKGROUND": "false"} + log = (tmp_path / "server.log").open("w+") + process = subprocess.Popen([binary, "serve", "--insecure-cookies", "--http-port", str(port), "--data-dir", str(data)], env=env, stdout=log, stderr=log) + base = f"http://127.0.0.1:{port}" + try: + _wait_reachable_or_fail(process, port, tmp_path / "server.log") + yield base, data + finally: + process.terminate() + process.wait(timeout=10) + log.close() def admin(base): @@ -331,8 +331,9 @@ def test_missing_backend_only_blocks_serving_not_offline_schema_apply(tmp_path): applied = subprocess.run([binary, "schema", "apply", str(schema), "--data-dir", str(data)], env=env, capture_output=True, text=True, timeout=20) assert applied.returncode == 0, applied.stdout + applied.stderr assert (data / "data.db").exists() - served = subprocess.run([binary, "serve", "--http-port", str(_free_port()), "--data-dir", str(data)], env=env, capture_output=True, text=True, timeout=20) - assert served.returncode != 0 and "ImageExecutableUnavailable" in served.stderr - env["ZIGBASE_IMAGEMAGICK_EXECUTABLE"] = "convert" - invalid = subprocess.run([binary, "serve", "--data-dir", str(data)], env=env, capture_output=True, text=True, timeout=20) - assert invalid.returncode != 0 and "ZIGBASE_IMAGEMAGICK_EXECUTABLE" in invalid.stderr + with _reserve_port() as port: + served = subprocess.run([binary, "serve", "--http-port", str(port), "--data-dir", str(data)], env=env, capture_output=True, text=True, timeout=20) + assert served.returncode != 0 and "ImageExecutableUnavailable" in served.stderr + env["ZIGBASE_IMAGEMAGICK_EXECUTABLE"] = "convert" + invalid = subprocess.run([binary, "serve", "--data-dir", str(data)], env=env, capture_output=True, text=True, timeout=20) + assert invalid.returncode != 0 and "ZIGBASE_IMAGEMAGICK_EXECUTABLE" in invalid.stderr