Private
Public Access
0
0
Files
manual_slop/tests/test_test_sandbox.py
T

412 lines
16 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.
Per Python's sys.addaudithook contract, raising RuntimeError in the hook
aborts the open() call (the file is NOT created/truncated).
[C: tests/conftest.py:_sandbox_audit_hook]"""
bad_path = Path(__file__).resolve().parent.parent / "_test_sandbox_probe.txt"
try:
with pytest.raises(RuntimeError, match="TEST_SANDBOX"):
bad_path.write_text("corrupt", encoding="utf-8")
assert not bad_path.exists(), (
f"TEST_SANDBOX_VIOLATION: file {bad_path} should NOT have been created"
)
finally:
if 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.initialize_paths(config_path) makes all path getters return paths
rooted at config_path's [paths] section (or default).
[C: src/paths.py:initialize_paths, sloppy.py:main]"""
from src import paths
config_path = tmp_path / "my_config.toml"
config_path.write_text("[ai]\nprovider='gemini'\n", encoding="utf-8")
paths.initialize_paths(config_path)
try:
assert paths.get_config_path() == config_path
assert paths.get_global_presets_path().name == "presets.toml"
assert paths.get_logs_dir().name == "sessions"
finally:
paths.reset_paths()
def test_paths_runtime_refresh_atomic_swap(tmp_path) -> None:
"""Calling initialize_paths() a second time atomically swaps the singleton.
Reader threads see the new config immediately. The PathsConfig is frozen.
[C: src/paths.py:initialize_paths, src/paths.py:PathsConfig]"""
from src import paths
cfg_a = tmp_path / "a.toml"
cfg_b = tmp_path / "b.toml"
cfg_a.write_text("[ai]\nprovider='gemini-a'\n", encoding="utf-8")
cfg_b.write_text("[ai]\nprovider='gemini-b'\n", encoding="utf-8")
paths.initialize_paths(cfg_a)
assert paths.get_config_path() == cfg_a
paths.initialize_paths(cfg_b)
assert paths.get_config_path() == cfg_b
paths.reset_paths()
def test_paths_uninitialized_raises(tmp_path) -> None:
"""After explicit paths.reset_paths() (i.e., user CLEARED the singleton
after a previous init), a getter raises RuntimeError. This is the
"bad programmer" detection — once cleared, you must re-init.
[C: src/paths.py:_cfg]"""
from src import paths
paths.initialize_paths(tmp_path / "dummy.toml")
paths.reset_paths()
with pytest.raises(RuntimeError, match="not initialized"):
paths.get_logs_dir()
def test_paths_module_load_initializes_defaults(tmp_path) -> None:
"""src/paths.py initializes _PATHS_CONFIG with defaults at module load.
This means subprocess imports that don't go through conftest.py (e.g.,
_run_in_subprocess tests) still have valid paths for any src/* module
that triggers a paths getter at import time (e.g., theme_2.load_themes).
[C: src/paths.py:_module_init_default]"""
import importlib
import src.paths as paths_module
# Reload to simulate fresh module load in subprocess
importlib.reload(paths_module)
# After module reload, defaults should be set
assert paths_module._PATHS_CONFIG is not None, (
"src.paths must initialize _PATHS_CONFIG at module load "
"so subprocess imports don't trigger 'paths not initialized' errors."
)
default_logs = paths_module._PATHS_CONFIG.logs_dir
assert default_logs.name == "sessions", (
f"default logs_dir should end in 'sessions'; got {default_logs}"
)
def test_sloppy_py_parses_config_flag() -> None:
"""sloppy.py has a --config argparse argument that calls initialize_paths."""
import ast
sloppy = Path(__file__).resolve().parent.parent / "sloppy.py"
tree = ast.parse(sloppy.read_text(encoding="utf-8"))
found_config_arg = False
found_init_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 == "initialize_paths":
found_init_call = True
assert found_config_arg, "sloppy.py must have a --config argparse argument"
assert found_init_call, "sloppy.py must call paths.initialize_paths(args.config)"
def test_pyproject_toml_basetemp_is_under_tests() -> None:
"""pyproject.toml contains --basetemp=tests/artifacts/_pytest_tmp."""
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
text = pyproject.read_text(encoding="utf-8")
assert "--basetemp=tests/artifacts/_pytest_tmp" in text, (
"pyproject.toml must set addopts = '--basetemp=tests/artifacts/_pytest_tmp' "
"so the FR1 runtime guard's allowlist can be a single rule."
)
def test_isolate_workspace_does_not_use_tmp_path_factory_for_infra() -> None:
"""isolate_workspace fixture does not use tmp_path_factory.mktemp."""
import ast
conftest = Path(__file__).resolve().parent / "conftest.py"
tree = ast.parse(conftest.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "isolate_workspace":
body = node.body
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
body = body[1:]
body_src = "\n".join(ast.unparse(stmt) for stmt in body)
assert "tmp_path_factory.mktemp" not in body_src, (
"isolate_workspace must not use tmp_path_factory.mktemp; "
"use _ISOLATION_WORKSPACE under tests/artifacts/ instead."
)
assert "_ISOLATION_WORKSPACE" in body_src, (
"isolate_workspace should reference _ISOLATION_WORKSPACE"
)
return
raise AssertionError("isolate_workspace fixture not found in conftest.py")
def test_appcontroller_init_does_not_load_config() -> None:
"""AppController.__init__ must not call init_state() or load_config() —
fixtures apply before App.__init__; loading config in AppController.__init__
would race against the autouse isolate_workspace."""
import ast
app_controller = Path(__file__).resolve().parent.parent / "src" / "app_controller.py"
tree = ast.parse(app_controller.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "__init__":
src = ast.unparse(node)
assert "init_state()" not in src, (
"AppController.__init__ must not call init_state() "
"(this would trigger config reads before fixtures apply)"
)
assert "load_config()" not in src, (
"AppController.__init__ must not call load_config() "
"(this would trigger config reads before fixtures apply)"
)
return
raise AssertionError("AppController.__init__ not found")
def test_config_overrides_toml_has_paths_section() -> None:
"""The auto-generated config_overrides.toml must include a [paths] section
that overrides every path getter to point inside _ISOLATION_WORKSPACE.
This is the v2 design (FR2 + per-path routing via config.toml, no env vars).
[C: tests/conftest.py:isolate_workspace]"""
import tomllib
runs = sorted(Path("tests/artifacts").glob("_isolation_workspace_*"))
assert runs, "no isolation workspaces found — did a test run yet?"
latest = runs[-1]
config_file = latest / "config_overrides.toml"
assert config_file.exists(), f"missing {config_file}"
with open(config_file, "rb") as f:
cfg = tomllib.load(f)
assert "paths" in cfg, (
f"config_overrides.toml must contain a [paths] section; "
f"got sections: {list(cfg.keys())}"
)
paths = cfg["paths"]
expected_keys = {
"presets", "tool_presets", "personas", "themes",
"workspace_profiles", "credentials", "logs_dir", "scripts_dir",
}
missing = expected_keys - set(paths.keys())
assert not missing, f"missing [paths] keys: {missing}"
for key, value in paths.items():
assert str(latest) in str(value), (
f"[paths].{key} = '{value}' does not point inside {latest}"
)
def test_path_getters_are_trivial_field_access() -> None:
"""Every global path getter in src/paths.py is a trivial field access on the
PathsConfig singleton (return _cfg().<field>). They must NOT do file I/O
or call _resolve_path() (which is internal-only, called from initialize_paths).
This enforces the v3 design: explicit init at startup, trivial getters."""
import ast
paths_py = Path(__file__).resolve().parent.parent / "src" / "paths.py"
tree = ast.parse(paths_py.read_text(encoding="utf-8"))
trivial_getters = [
"get_global_presets_path", "get_global_tool_presets_path",
"get_global_personas_path", "get_global_themes_path",
"get_global_workspace_profiles_path", "get_credentials_path",
"get_logs_dir", "get_scripts_dir",
]
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name in trivial_getters:
src = ast.unparse(node)
assert "_cfg()" in src, (
f"{node.name} must call _cfg() to read from the PathsConfig singleton; "
f"got direct file I/O or env var lookup instead."
)
assert "_resolve_path(" not in src, (
f"{node.name} must NOT call _resolve_path(); that's internal-only "
f"(called once from initialize_paths)."
)
assert "os.environ" not in src, (
f"{node.name} must NOT use os.environ directly; env vars are read once "
f"during initialize_paths()."
)
def test_initialize_paths_thread_safe_atomic_swap(tmp_path) -> None:
"""initialize_paths() uses an RLock; concurrent swaps don't corrupt the
singleton. Reader threads always see a consistent PathsConfig snapshot
(frozen dataclass). Per the user's "bad programmers take shortcuts" rule:
no torn writes, no partial reads.
[C: src/paths.py:_PATHS_LOCK, src/paths.py:PathsConfig]"""
import threading
import tomllib
from src import paths
cfg_a = tmp_path / "a.toml"
cfg_b = tmp_path / "b.toml"
cfg_a.write_bytes(b"[paths]\nlogs_dir = 'C:/tmp/thread_a'\n")
cfg_b.write_bytes(b"[paths]\nlogs_dir = 'C:/tmp/thread_b'\n")
errors = []
def swap(cfg, n):
for _ in range(n):
try:
paths.initialize_paths(cfg)
except Exception as e:
errors.append(e)
t1 = threading.Thread(target=swap, args=(cfg_a, 100))
t2 = threading.Thread(target=swap, args=(cfg_b, 100))
t1.start(); t2.start()
t1.join(); t2.join()
assert not errors, f"thread-safety violation: {errors}"
paths.reset_paths()
def test_pathsconfig_is_frozen_dataclass() -> None:
"""PathsConfig uses @dataclass(frozen=True) so individual field reads are
atomic and cannot be mutated by readers. Per user directive: gated
transactions, no data race over config.
[C: src/paths.py:PathsConfig]"""
import dataclasses
import tempfile, tomli_w
from src import paths
params = getattr(paths.PathsConfig, "__dataclass_params__", None)
assert params is not None, "PathsConfig must be a dataclass"
assert params.frozen is True, "PathsConfig must be @dataclass(frozen=True)"
with tempfile.NamedTemporaryFile(suffix=".toml", delete=False, mode="wb") as f:
tomli_w.dump({"paths": {"logs_dir": "C:/tmp/frozen_test"}}, f)
cfg = Path(f.name)
try:
paths.initialize_paths(cfg)
with pytest.raises(dataclasses.FrozenInstanceError):
paths._PATHS_CONFIG.presets = Path("/tmp/should_fail")
finally:
paths.reset_paths()
cfg.unlink()
@pytest.mark.skipif(os.name != "nt", reason="Windows-only sandbox wrapper")
def test_run_tests_sandboxed_whatif() -> None:
"""pwsh -File scripts/run_tests_sandboxed.ps1 -WhatIf exits 0 without
acquiring a restricted token or launching pytest.
[C: scripts/run_tests_sandboxed.ps1]"""
result = subprocess.run(
["pwsh", "-File", "scripts/run_tests_sandboxed.ps1", "-WhatIf"],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, (
f"Expected exit 0, got {result.returncode}: {result.stderr}"
)
assert "whatif" in result.stdout.lower() or "[run-tests-sandboxed-whatif]" in result.stdout