tests/test_lazy_secrets_dispatch.py::TestUpdatePathE2E ran the real `hermes update --check` bare, so the child's `git fetch origin main` hit github.com on every CI run. During the 2026-08-17 GitHub incident the fetch stalled past the 30s subprocess timeout and both update tests went red on main for hours with zero code change (slices seen on runs 32003200300 and 32011520139). These tests assert the lazy-crypto / no-self-lock dispatch invariants, not update connectivity. Rewrite all git remote URLs in the child to an unreachable file:// path via GIT_CONFIG_* env overrides: the update path still exercises its full parser/dispatch/fetch code, but the fetch now fails in milliseconds, deterministically, offline. Exit code 1 was already accepted by the assertions. Before/after under a blackhole proxy simulating the outage: OLD: TimeoutExpired after 20s (reproduces the CI failure) NEW: completes in 0.2s, exit=1
236 lines
9.4 KiB
Python
236 lines
9.4 KiB
Python
"""End-to-end tests for lazy cryptography loading.
|
|
|
|
These tests invoke the real CLI paths as subprocesses to verify:
|
|
1. `hermes secrets bitwarden setup --help` works (dispatch path)
|
|
2. `hermes update --check` works (update path)
|
|
3. `hermes secrets bitwarden disable` works (handler execution)
|
|
4. `hermes secrets onepassword status` works (lazy backend loads on demand)
|
|
|
|
Unlike test_lazy_secrets_import.py (which inspects sys.modules), these
|
|
run the actual commands and verify exit codes — the exact paths the
|
|
reviewer flagged as unproven.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def _run_hermes(args: list[str], timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
|
"""Run hermes CLI as a subprocess from repo root.
|
|
|
|
The child runs with all git remote URLs rewritten to an unreachable
|
|
local path (GIT_CONFIG_* env overrides). These tests assert the
|
|
lazy-crypto / no-self-lock invariants of the dispatch path, NOT update
|
|
connectivity — but ``hermes update --check`` really does ``git fetch``
|
|
against github.com when run bare. Under remote throttling that fetch
|
|
can exceed the subprocess timeout and TimeoutExpired the test (exactly
|
|
what happened on CI during the 2026-08-17 GitHub incident: both update
|
|
tests red on main for hours with no code change). Rewriting the URLs
|
|
makes the fetch fail instantly and deterministically; the update path
|
|
still exercises its full parser/dispatch/fetch code and exits 1, which
|
|
the assertions already accept.
|
|
"""
|
|
repo_root = Path(__file__).parent.parent
|
|
env = dict(os.environ)
|
|
env.update(
|
|
{
|
|
"GIT_CONFIG_COUNT": "2",
|
|
# Rewrite every https:// and ssh remote to a nonexistent local
|
|
# path so any fetch fails in milliseconds without touching the
|
|
# network. insteadOf matching is prefix-based.
|
|
"GIT_CONFIG_KEY_0": "url.file:///nonexistent-hermes-test-remote/.insteadOf",
|
|
"GIT_CONFIG_VALUE_0": "https://",
|
|
"GIT_CONFIG_KEY_1": "url.file:///nonexistent-hermes-test-remote/.insteadOf",
|
|
"GIT_CONFIG_VALUE_1": "git@",
|
|
}
|
|
)
|
|
return subprocess.run(
|
|
[sys.executable, "-m", "hermes_cli.main"] + args,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(repo_root),
|
|
env=env,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
class TestSecretsDispatchE2E:
|
|
"""End-to-end secrets dispatch — the path that must not self-lock."""
|
|
|
|
def test_bitwarden_setup_help(self) -> None:
|
|
"""`hermes secrets bitwarden setup --help` must exit 0 and print usage.
|
|
|
|
This is the exact path that triggered the #86781 self-lock loop on
|
|
Windows: setup/parser nested under lazy-loaded backend.
|
|
"""
|
|
result = _run_hermes(["secrets", "bitwarden", "setup", "--help"])
|
|
assert result.returncode == 0, (
|
|
f"bitwarden setup --help failed:\n"
|
|
f"stdout: {result.stdout}\n"
|
|
f"stderr: {result.stderr}"
|
|
)
|
|
assert "usage" in result.stdout.lower()
|
|
|
|
def test_bitwarden_status(self) -> None:
|
|
"""`hermes secrets bitwarden status` must exit 0 (runs lazy backend)."""
|
|
result = _run_hermes(["secrets", "bitwarden", "status"])
|
|
# status may return non-zero if not configured, but must NOT crash
|
|
# with import errors, recursion, or missing subcommand
|
|
assert result.returncode in (0, 1), (
|
|
f"bitwarden status crashed:\n"
|
|
f"stdout: {result.stdout}\n"
|
|
f"stderr: {result.stderr}"
|
|
)
|
|
# Must not contain import errors
|
|
assert "ImportError" not in result.stderr
|
|
assert "cannot import name" not in result.stderr
|
|
|
|
def test_bitwarden_disable(self) -> None:
|
|
"""`hermes secrets bitwarden disable` must exit 0."""
|
|
result = _run_hermes(["secrets", "bitwarden", "disable"])
|
|
assert result.returncode == 0, (
|
|
f"bitwarden disable failed:\n"
|
|
f"stdout: {result.stdout}\n"
|
|
f"stderr: {result.stderr}"
|
|
)
|
|
|
|
def test_onepassword_status(self) -> None:
|
|
"""`hermes secrets onepassword status` must exit 0 (1Password lazy backend)."""
|
|
result = _run_hermes(["secrets", "onepassword", "status"])
|
|
assert result.returncode in (0, 1), (
|
|
f"onepassword status crashed:\n"
|
|
f"stdout: {result.stdout}\n"
|
|
f"stderr: {result.stderr}"
|
|
)
|
|
assert "ImportError" not in result.stderr
|
|
|
|
def test_onepassword_setup_help(self) -> None:
|
|
"""`hermes secrets onepassword setup --help` must exit 0."""
|
|
result = _run_hermes(["secrets", "onepassword", "setup", "--help"])
|
|
assert result.returncode in (0, 2), (
|
|
f"onepassword setup --help failed:\n"
|
|
f"stdout: {result.stdout}\n"
|
|
f"stderr: {result.stderr}"
|
|
)
|
|
assert "ImportError" not in result.stderr
|
|
|
|
|
|
class TestUpdatePathE2E:
|
|
"""Update path — must not load cryptography.
|
|
|
|
These tests invoke the real `hermes update --check` path as a subprocess.
|
|
The conftest.py live-system guard blocks this because the command string
|
|
contains "update"; we bypass with the pytest mark.
|
|
"""
|
|
|
|
@pytest.mark.live_system_guard_bypass
|
|
def test_update_check_clean(self) -> None:
|
|
"""`hermes update --check` must not load cryptography._rust."""
|
|
result = _run_hermes(["update", "--check"])
|
|
assert result.returncode in (0, 1, 2), (
|
|
f"update --check crashed:\n"
|
|
f"stdout: {result.stdout}\n"
|
|
f"stderr: {result.stderr}"
|
|
)
|
|
# No import errors
|
|
assert "ImportError" not in result.stderr
|
|
assert "cannot import name" not in result.stderr
|
|
|
|
@pytest.mark.live_system_guard_bypass
|
|
def test_update_no_self_lock(self) -> None:
|
|
"""Update path must not self-lock (cryptography._rust absent)."""
|
|
result = _run_hermes(["update", "--check"])
|
|
# The check itself may return non-zero (e.g. no updates), but
|
|
# must not contain the self-lock defer message
|
|
assert "deferred" not in result.stderr.lower()
|
|
assert "self-lock" not in result.stderr.lower()
|
|
assert "_rust.pyd" not in result.stderr.lower()
|
|
|
|
@pytest.mark.live_system_guard_bypass
|
|
def test_main_update_check_crypto_absent_in_sys_modules(self) -> None:
|
|
"""Decisive invariant: invoking main() with argv=['hermes','update','--check']
|
|
leaves cryptography.hazmat.bindings._rust absent from sys.modules.
|
|
|
|
This is the exact invariant review flagged as unproven (#86782 review
|
|
2026-08-15): an import-only test cannot observe lazy failures, because
|
|
parser construction happens inside main(). Run main() itself in a
|
|
subprocess, let it execute the update path, then assert sys.modules.
|
|
|
|
The check must run before _dispatch_update calls its (potentially
|
|
lazy) network layer, so we instrument sys.modules immediately after
|
|
parse_args() and before dispatch returns, using a monkeypatched
|
|
_cmd_update_check that captures state then short-circuits.
|
|
"""
|
|
script = """
|
|
import sys
|
|
from unittest.mock import patch
|
|
|
|
crypto_seen_at_dispatch = []
|
|
|
|
def capture_update_check(*args, **kwargs):
|
|
# Run just before the real handler would; record crypto state.
|
|
crypto_seen_at_dispatch.append(
|
|
'cryptography.hazmat.bindings._rust' in sys.modules
|
|
)
|
|
# Short-circuit: don't actually call the network in tests.
|
|
return 0
|
|
|
|
sys.argv = ['hermes', 'update', '--check']
|
|
|
|
import hermes_cli.main as m
|
|
|
|
# Patch the update handler so main() exercises its parser + dispatch
|
|
# without doing network I/O. cmd_update (in main.py) calls
|
|
# _self()._cmd_update_check(branch=..., branch_explicit=...) where _self()
|
|
# resolves the hermes_cli.main module's lazily re-exported attribute —
|
|
# so the patch must land on hermes_cli.main._cmd_update_check.
|
|
with patch('hermes_cli.main._cmd_update_check', capture_update_check):
|
|
try:
|
|
m.main()
|
|
except SystemExit as e:
|
|
# argparse may sys.exit for --help / bad args; ignore for this probe
|
|
if e.code not in (0, None):
|
|
print(f'FAIL: main() exited with code {e.code}')
|
|
sys.exit(1)
|
|
|
|
# 1. main() must have dispatched into our capture hook
|
|
if not crypto_seen_at_dispatch:
|
|
print('FAIL: update --check did not dispatch to _cmd_update_check')
|
|
sys.exit(1)
|
|
|
|
# 2. At dispatch time, crypto must NOT be loaded
|
|
if crypto_seen_at_dispatch[0]:
|
|
print('FAIL: cryptography._rust loaded by main() before update dispatch')
|
|
sys.exit(1)
|
|
|
|
# 3. After main() returned, crypto must STILL not be loaded
|
|
if 'cryptography.hazmat.bindings._rust' in sys.modules:
|
|
print('FAIL: cryptography._rust present in sys.modules after main()')
|
|
sys.exit(1)
|
|
|
|
print('PASS: main() update --check path never loaded cryptography._rust')
|
|
sys.exit(0)
|
|
"""
|
|
repo_root = Path(__file__).parent.parent
|
|
probe = repo_root / "_test_main_update_crypto_probe.py"
|
|
probe.write_text(script)
|
|
try:
|
|
result = subprocess.run(
|
|
[sys.executable, probe.name],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(repo_root),
|
|
timeout=60,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"Decisive main()-level probe failed:\n"
|
|
f"stdout: {result.stdout}\n"
|
|
f"stderr: {result.stderr}"
|
|
)
|
|
assert "PASS" in result.stdout
|
|
finally:
|
|
probe.unlink(missing_ok=True) |