"""Tests for tools/path_guard.py — poka-yoke hardcoded path detection.""" import os import tempfile from pathlib import Path import pytest from tools.path_guard import ( PathGuardError, scan_directory, scan_file_for_violations, validate_path, validate_tool_paths, ) class TestValidatePath: """Runtime path validation.""" def test_valid_relative_path(self): assert validate_path("tools/file_tools.py") == "tools/file_tools.py" def test_valid_absolute_path(self): assert validate_path("/tmp/test.txt") == "/tmp/test.txt" def test_valid_hermes_home(self): assert validate_path(os.path.expanduser("~/.hermes/config.yaml")) is not None def test_reject_users_hardcoded(self): with pytest.raises(PathGuardError, match="/Users/"): validate_path("/Users/someone_else/.hermes/config") def test_reject_home_hardcoded(self): with pytest.raises(PathGuardError, match="/home/"): validate_path("/home/user/.hermes/config") def test_empty_path(self): assert validate_path("") == "" assert validate_path(None) is None def test_non_string(self): assert validate_path(42) == 42 class TestValidateToolPaths: """Batch path validation.""" def test_all_valid(self): paths = ["tools/file.py", "/tmp/x.txt", "relative/path.py"] assert validate_tool_paths(paths) == paths def test_mixed_invalid(self): with pytest.raises(PathGuardError): validate_tool_paths(["tools/file.py", "/Users/someone_else/secret.txt"]) def test_skips_non_strings(self): assert validate_tool_paths([None, 42, "valid.py"]) == ["valid.py"] class TestScanFileForViolations: """Static file scanning.""" def test_clean_file(self, tmp_path): f = tmp_path / "clean.py" f.write_text("import os\nHOME = os.environ['HOME']\n") assert scan_file_for_violations(str(f)) == [] def test_hardcoded_users(self, tmp_path): f = tmp_path / "bad.py" f.write_text("CONFIG = '/Users/apayne/.hermes/config.yaml'\n") violations = scan_file_for_violations(str(f)) assert len(violations) == 1 assert "/Users//" in violations[0][2] def test_hardcoded_home(self, tmp_path): f = tmp_path / "bad2.py" f.write_text("PATH = '/home/deploy/.hermes/state.db'\n") violations = scan_file_for_violations(str(f)) assert len(violations) == 1 assert "/home//" in violations[0][2] def test_tilde_in_expanduser_ok(self, tmp_path): f = tmp_path / "ok.py" f.write_text("p = os.path.expanduser('~/.hermes/config')\n") assert scan_file_for_violations(str(f)) == [] def test_tilde_in_display_ok(self, tmp_path): f = tmp_path / "ok2.py" f.write_text('print("~/config saved")\n') assert scan_file_for_violations(str(f)) == [] def test_noqa_escape(self, tmp_path): f = tmp_path / "noqa.py" f.write_text("PATH = '/Users/apayne/test' # noqa: hardcoded-path-ok\n") assert scan_file_for_violations(str(f)) == [] def test_comments_skipped(self, tmp_path): f = tmp_path / "comment.py" f.write_text("# PATH = '/Users/apayne/test'\n") assert scan_file_for_violations(str(f)) == [] class TestScanDirectory: """Directory scanning.""" def test_clean_tree(self, tmp_path): (tmp_path / "clean.py").write_text("import os\n") (tmp_path / "sub").mkdir() (tmp_path / "sub" / "also_clean.py").write_text("x = 1\n") assert scan_directory(str(tmp_path)) == [] def test_finds_violations(self, tmp_path): (tmp_path / "bad.py").write_text("P = '/Users/x/.hermes'\n") results = scan_directory(str(tmp_path)) assert len(results) == 1 assert results[0][0].endswith("bad.py") def test_skips_tests(self, tmp_path): (tmp_path / "test_something.py").write_text("P = '/Users/x/.hermes'\n") assert scan_directory(str(tmp_path)) == [] def test_skips_pycache(self, tmp_path): cache = tmp_path / "__pycache__" cache.mkdir() (cache / "cached.py").write_text("P = '/Users/x/.hermes'\n") assert scan_directory(str(tmp_path)) == []