* refactor: re-architect tests to mirror the codebase
* Update tests.yml
* fix: add missing tool_error imports after registry refactor
* fix(tests): replace patch.dict with monkeypatch to prevent env var leaks under xdist
patch.dict(os.environ) can leak TERMINAL_ENV across xdist workers,
causing test_code_execution tests to hit the Modal remote path.
* fix(tests): fix update_check and telegram xdist failures
- test_update_check: replace patch("hermes_cli.banner.os.getenv") with
monkeypatch.setenv("HERMES_HOME") — banner.py no longer imports os
directly, it uses get_hermes_home() from hermes_constants.
- test_telegram_conflict/approval_buttons: provide real exception classes
for telegram.error mock (NetworkError, TimedOut, BadRequest) so the
except clause in connect() doesn't fail with "catching classes that do
not inherit from BaseException" when xdist pollutes sys.modules.
* fix(tests): accept unavailable_models kwarg in _prompt_model_selection mock
131 lines
4.3 KiB
Python
131 lines
4.3 KiB
Python
"""Security-focused integration tests for CLI worktree setup."""
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def git_repo(tmp_path):
|
|
"""Create a temporary git repo for testing real cli._setup_worktree behavior."""
|
|
repo = tmp_path / "test-repo"
|
|
repo.mkdir()
|
|
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
|
subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True)
|
|
subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True, capture_output=True)
|
|
(repo / "README.md").write_text("# Test Repo\n")
|
|
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
|
|
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo, check=True, capture_output=True)
|
|
return repo
|
|
|
|
|
|
def _force_remove_worktree(info: dict | None) -> None:
|
|
if not info:
|
|
return
|
|
subprocess.run(
|
|
["git", "worktree", "remove", info["path"], "--force"],
|
|
cwd=info["repo_root"],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
subprocess.run(
|
|
["git", "branch", "-D", info["branch"]],
|
|
cwd=info["repo_root"],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
|
|
|
|
class TestWorktreeIncludeSecurity:
|
|
def test_rejects_parent_directory_file_traversal(self, git_repo):
|
|
import cli as cli_mod
|
|
|
|
outside_file = git_repo.parent / "sensitive.txt"
|
|
outside_file.write_text("SENSITIVE DATA")
|
|
(git_repo / ".worktreeinclude").write_text("../sensitive.txt\n")
|
|
|
|
info = None
|
|
try:
|
|
info = cli_mod._setup_worktree(str(git_repo))
|
|
assert info is not None
|
|
|
|
wt_path = Path(info["path"])
|
|
assert not (wt_path.parent / "sensitive.txt").exists()
|
|
assert not (wt_path / "../sensitive.txt").resolve().exists()
|
|
finally:
|
|
_force_remove_worktree(info)
|
|
|
|
def test_rejects_parent_directory_directory_traversal(self, git_repo):
|
|
import cli as cli_mod
|
|
|
|
outside_dir = git_repo.parent / "outside-dir"
|
|
outside_dir.mkdir()
|
|
(outside_dir / "secret.txt").write_text("SENSITIVE DIR DATA")
|
|
(git_repo / ".worktreeinclude").write_text("../outside-dir\n")
|
|
|
|
info = None
|
|
try:
|
|
info = cli_mod._setup_worktree(str(git_repo))
|
|
assert info is not None
|
|
|
|
wt_path = Path(info["path"])
|
|
escaped_dir = wt_path.parent / "outside-dir"
|
|
assert not escaped_dir.exists()
|
|
assert not escaped_dir.is_symlink()
|
|
finally:
|
|
_force_remove_worktree(info)
|
|
|
|
def test_rejects_symlink_that_resolves_outside_repo(self, git_repo):
|
|
import cli as cli_mod
|
|
|
|
outside_file = git_repo.parent / "linked-secret.txt"
|
|
outside_file.write_text("LINKED SECRET")
|
|
(git_repo / "leak.txt").symlink_to(outside_file)
|
|
(git_repo / ".worktreeinclude").write_text("leak.txt\n")
|
|
|
|
info = None
|
|
try:
|
|
info = cli_mod._setup_worktree(str(git_repo))
|
|
assert info is not None
|
|
|
|
assert not (Path(info["path"]) / "leak.txt").exists()
|
|
finally:
|
|
_force_remove_worktree(info)
|
|
|
|
def test_allows_valid_file_include(self, git_repo):
|
|
import cli as cli_mod
|
|
|
|
(git_repo / ".env").write_text("SECRET=***\n")
|
|
(git_repo / ".worktreeinclude").write_text(".env\n")
|
|
|
|
info = None
|
|
try:
|
|
info = cli_mod._setup_worktree(str(git_repo))
|
|
assert info is not None
|
|
|
|
copied = Path(info["path"]) / ".env"
|
|
assert copied.exists()
|
|
assert copied.read_text() == "SECRET=***\n"
|
|
finally:
|
|
_force_remove_worktree(info)
|
|
|
|
def test_allows_valid_directory_include(self, git_repo):
|
|
import cli as cli_mod
|
|
|
|
assets_dir = git_repo / ".venv" / "lib"
|
|
assets_dir.mkdir(parents=True)
|
|
(assets_dir / "marker.txt").write_text("venv marker")
|
|
(git_repo / ".worktreeinclude").write_text(".venv\n")
|
|
|
|
info = None
|
|
try:
|
|
info = cli_mod._setup_worktree(str(git_repo))
|
|
assert info is not None
|
|
|
|
linked_dir = Path(info["path"]) / ".venv"
|
|
assert linked_dir.is_symlink()
|
|
assert (linked_dir / "lib" / "marker.txt").read_text() == "venv marker"
|
|
finally:
|
|
_force_remove_worktree(info)
|