194 lines
7.2 KiB
Python
194 lines
7.2 KiB
Python
"""Tests for scripts/audit_test_sandbox_violations.py (Phase 2, FR4) and
|
|
the Python audit guard in tests/conftest.py (Phase 3, FR1).
|
|
"""
|
|
from __future__ import annotations
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def test_audit_runs_without_error() -> None:
|
|
"""The audit script runs and exits cleanly."""
|
|
result = subprocess.run(
|
|
[sys.executable, "scripts/audit_test_sandbox_violations.py"],
|
|
capture_output=True, text=True, cwd=str(Path(__file__).resolve().parent.parent)
|
|
)
|
|
assert result.returncode in (0, 1), f"Unexpected exit code: {result.returncode}"
|
|
|
|
|
|
def test_audit_flags_toml_basename_pattern() -> None:
|
|
"""A test source line with Path('manual_slop.toml') is flagged by the pattern."""
|
|
pattern = re.compile(r'Path\(["\'](?:manual_slop|config|credentials|presets|personas|tool_presets|workspace_profiles|project|manualslop_layout|manual_slop_history)\.toml["\']')
|
|
assert pattern.search('Path("manual_slop.toml").write_text("x")'), "Pattern should match"
|
|
|
|
|
|
def test_audit_flags_project_root_path() -> None:
|
|
"""A test source line with Path('C:/projects/...') is flagged."""
|
|
pattern = re.compile(r'Path\(["\']C:[/\\]+projects')
|
|
assert pattern.search('base_dir = Path("C:/projects/test")'), "Pattern should match"
|
|
|
|
|
|
def test_audit_flags_tempfile_mkdtemp() -> None:
|
|
"""A test source line with bare tempfile.mkdtemp() is flagged."""
|
|
pattern = re.compile(r"tempfile\.mk(?:dt|st)emp\(")
|
|
assert pattern.search('tmp = tempfile.mkdtemp()'), "Pattern should match"
|
|
assert pattern.search('tmp = tempfile.mkstemp()'), "Pattern should match"
|
|
|
|
|
|
def test_audit_flags_tests_artifacts_literal() -> None:
|
|
"""A test source line with Path('tests/artifacts/...') literal is flagged."""
|
|
pattern = re.compile(r'Path\(["\']tests/artifacts/')
|
|
assert pattern.search('p = Path("tests/artifacts/some_file.txt")'), "Pattern should match"
|
|
|
|
|
|
def test_audit_passes_clean_file() -> None:
|
|
"""A test source line using tmp_path passes the audit patterns."""
|
|
content = 'tmp_path.joinpath("foo.txt").write_text("x")\n'
|
|
patterns = [
|
|
re.compile(r'Path\(["\'](?:manual_slop|config)\.toml["\']'),
|
|
re.compile(r'Path\(["\']C:[/\\]+projects'),
|
|
re.compile(r'Path\(["\']tests/artifacts/'),
|
|
re.compile(r"tempfile\.mk(?:dt|st)emp\("),
|
|
]
|
|
for p in patterns:
|
|
assert not p.search(content), f"Pattern {p.pattern} should not match clean content"
|
|
|
|
|
|
def test_audit_subprocess_clean_dir_exits_zero() -> None:
|
|
"""The audit returns 0 on a clean test directory."""
|
|
tmp_dir = Path("tests/artifacts/_audit_subprocess_clean")
|
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
good = tmp_dir / "test_good.py"
|
|
good.write_text("def test_x(tmp_path): tmp_path.joinpath('f').write_text('x')\n", encoding="utf-8")
|
|
try:
|
|
result = subprocess.run(
|
|
[sys.executable, "scripts/audit_test_sandbox_violations.py", "--tests-dir", str(tmp_dir), "--strict"],
|
|
capture_output=True, text=True,
|
|
)
|
|
assert result.returncode == 0, f"Expected exit 0, got {result.returncode}: {result.stdout}"
|
|
finally:
|
|
good.unlink(missing_ok=True)
|
|
tmp_dir.rmdir()
|
|
|
|
|
|
def test_audit_subprocess_bad_dir_exits_one() -> None:
|
|
"""The audit returns 1 on a directory with a bad pattern."""
|
|
tmp_dir = Path("tests/artifacts/_audit_subprocess_bad")
|
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
bad = tmp_dir / "test_bad.py"
|
|
bad.write_text('Path("manual_slop.toml").write_text("x")\n', encoding="utf-8")
|
|
try:
|
|
result = subprocess.run(
|
|
[sys.executable, "scripts/audit_test_sandbox_violations.py", "--tests-dir", str(tmp_dir), "--strict"],
|
|
capture_output=True, text=True,
|
|
)
|
|
assert result.returncode == 1, f"Expected exit 1, got {result.returncode}"
|
|
finally:
|
|
bad.unlink(missing_ok=True)
|
|
tmp_dir.rmdir()
|
|
|
|
|
|
def test_sandbox_blocks_writes_outside_tests_dir() -> None:
|
|
"""A write to <project_root>/manual_slop.toml raises TEST_SANDBOX_VIOLATION."""
|
|
bad_path = Path(__file__).resolve().parent.parent / "manual_slop.toml"
|
|
if bad_path.exists():
|
|
original = bad_path.read_bytes()
|
|
existed = True
|
|
else:
|
|
existed = False
|
|
original = b""
|
|
try:
|
|
with pytest.raises(RuntimeError, match="TEST_SANDBOX_VIOLATION"):
|
|
bad_path.write_text("corrupt", encoding="utf-8")
|
|
finally:
|
|
if existed:
|
|
bad_path.write_bytes(original)
|
|
elif bad_path.exists():
|
|
bad_path.unlink()
|
|
|
|
|
|
def test_sandbox_allows_writes_inside_tests_dir(tmp_path) -> None:
|
|
"""A write to tmp_path (which lives under tests/artifacts/_pytest_tmp) succeeds."""
|
|
target = tmp_path / "foo.txt"
|
|
target.write_text("ok", encoding="utf-8")
|
|
assert target.read_text(encoding="utf-8") == "ok"
|
|
|
|
|
|
def test_sandbox_allows_writes_inside_tests_artifacts() -> None:
|
|
"""A write to tests/artifacts/_sandbox_test_allows/foo.txt succeeds."""
|
|
p = Path("tests/artifacts/_sandbox_test_allows/foo.txt")
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
p.write_text("ok", encoding="utf-8")
|
|
assert p.read_text(encoding="utf-8") == "ok"
|
|
finally:
|
|
p.unlink(missing_ok=True)
|
|
if p.parent.exists():
|
|
p.parent.rmdir()
|
|
|
|
|
|
def test_sandbox_does_not_block_reads() -> None:
|
|
"""A read of <project_root>/pyproject.toml succeeds (reads are always allowed)."""
|
|
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
content = pyproject.read_text(encoding="utf-8")
|
|
assert "[tool.pytest.ini_options]" in content
|
|
|
|
|
|
def test_sandbox_allows_pytest_cache_write() -> None:
|
|
"""Writes under .pytest_cache are allowed (pytest internal cache)."""
|
|
cache_root = Path(__file__).resolve().parent.parent / ".pytest_cache"
|
|
probe = cache_root / "_sandbox_probe.txt"
|
|
cache_root.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
probe.write_text("ok", encoding="utf-8")
|
|
assert probe.read_text(encoding="utf-8") == "ok"
|
|
finally:
|
|
probe.unlink(missing_ok=True)
|
|
|
|
|
|
def test_config_override_via_cli_flag(tmp_path) -> None:
|
|
"""paths.set_config_override(path) makes get_config_path() return that path."""
|
|
from src import paths
|
|
config_path = tmp_path / "my_config.toml"
|
|
config_path.write_text("[ai]\nprovider='gemini'\n", encoding="utf-8")
|
|
original = paths._CONFIG_OVERRIDE
|
|
try:
|
|
paths.set_config_override(config_path)
|
|
assert paths.get_config_path() == config_path
|
|
finally:
|
|
paths.set_config_override(original)
|
|
|
|
|
|
def test_paths_get_config_path_no_env_fallback(monkeypatch) -> None:
|
|
"""Without an override AND without SLOP_CONFIG, get_config_path returns default."""
|
|
monkeypatch.delenv("SLOP_CONFIG", raising=False)
|
|
from src import paths
|
|
original = paths._CONFIG_OVERRIDE
|
|
try:
|
|
paths.set_config_override(None)
|
|
expected = Path(__file__).resolve().parent.parent / "config.toml"
|
|
assert paths.get_config_path() == expected
|
|
finally:
|
|
paths.set_config_override(original)
|
|
|
|
|
|
def test_sloppy_py_parses_config_flag() -> None:
|
|
"""sloppy.py has a --config argparse argument that calls set_config_override."""
|
|
import ast
|
|
sloppy = Path(__file__).resolve().parent.parent / "sloppy.py"
|
|
tree = ast.parse(sloppy.read_text(encoding="utf-8"))
|
|
found_config_arg = False
|
|
found_set_override_call = False
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Constant) and node.value == "--config":
|
|
found_config_arg = True
|
|
if isinstance(node, ast.Call):
|
|
func = node.func
|
|
if isinstance(func, ast.Name) and func.id == "set_config_override":
|
|
found_set_override_call = True
|
|
assert found_config_arg, "sloppy.py must have a --config argparse argument"
|
|
assert found_set_override_call, "sloppy.py must call paths.set_config_override(args.config)" |