Files
TheRock/rocm-systems/experimental/python/perfxpert/tests/test_cli/test_consent.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

303 lines
9.8 KiB
Python

"""Tests for `perfxpert.cli._consent` (Task 3, I10).
Covers:
* Round-trip grant + check for a (backend, cwd, file-set) triple.
* Invalidation when file-set changes.
* Invalidation when cwd changes.
* `PERFXPERT_ASSUME_CONSENT=1` env override skips the prompt.
* Non-TTY stdin without env override refuses with guidance.
* `XDG_CONFIG_HOME` is honored.
* HOME isolation — tests never touch the developer's real
`~/.config/perfxpert/consent.yaml`.
"""
from __future__ import annotations
import io
import os
from pathlib import Path
from unittest import mock
import pytest
from perfxpert.cli import _consent
from perfxpert.cli._consent import (
CONSENT_ASSUME_ENV,
consent_path,
cwd_hash,
file_set_hash,
grant_consent,
has_consent,
prompt_consent_interactive,
revoke_consent,
)
# ---------------------------------------------------------------------------
# Fixtures — isolate HOME + XDG_CONFIG_HOME so no tests touch the real home.
# ---------------------------------------------------------------------------
@pytest.fixture
def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect HOME + XDG_CONFIG_HOME to a tmp dir.
The consent cache lives under `$XDG_CONFIG_HOME/perfxpert/`; by
redirecting both we guarantee the real developer home never sees
test writes.
"""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config"))
return tmp_path
# ---------------------------------------------------------------------------
# Hash helpers.
# ---------------------------------------------------------------------------
def test_cwd_hash_stable_for_same_path(tmp_path: Path) -> None:
h1 = cwd_hash(tmp_path)
h2 = cwd_hash(tmp_path)
assert h1 == h2
assert len(h1) == 8
def test_cwd_hash_differs_across_paths(tmp_path: Path) -> None:
h1 = cwd_hash(tmp_path)
h2 = cwd_hash(tmp_path / "sub")
assert h1 != h2
def test_file_set_hash_is_order_independent(tmp_path: Path) -> None:
a = tmp_path / "a"
b = tmp_path / "b"
h1 = file_set_hash([(a, True, False), (b, False, True)])
h2 = file_set_hash([(b, False, True), (a, True, False)])
assert h1 == h2
def test_file_set_hash_changes_on_tracked_flag(tmp_path: Path) -> None:
"""Critical I10 invariant: (path, tracked=False) -> (path, tracked=True)
must invalidate consent because the user newly tracks the file in git.
"""
a = tmp_path / "a"
untracked = file_set_hash([(a, True, False)])
tracked = file_set_hash([(a, True, True)])
assert untracked != tracked
# ---------------------------------------------------------------------------
# Round-trip + invalidation.
# ---------------------------------------------------------------------------
def test_consent_round_trip(isolated_home: Path) -> None:
fset = file_set_hash([(isolated_home / "CLAUDE.md", False, False)])
assert has_consent("claude", isolated_home, fset) is False
grant_consent("claude", isolated_home, fset)
assert has_consent("claude", isolated_home, fset) is True
def test_consent_invalidated_when_file_set_changes(isolated_home: Path) -> None:
fset1 = file_set_hash([(isolated_home / "CLAUDE.md", False, False)])
fset2 = file_set_hash([(isolated_home / "CLAUDE.md", True, True)])
grant_consent("claude", isolated_home, fset1)
assert has_consent("claude", isolated_home, fset1) is True
# New file-set = different consent key → re-prompt required.
assert has_consent("claude", isolated_home, fset2) is False
def test_consent_invalidated_when_cwd_changes(
isolated_home: Path, tmp_path: Path
) -> None:
other = tmp_path / "other"
other.mkdir()
fset = file_set_hash([(other / "CLAUDE.md", False, False)])
grant_consent("claude", isolated_home, fset)
# Different cwd → consent does NOT transfer.
assert has_consent("claude", other, fset) is False
def test_consent_invalidated_across_backends(isolated_home: Path) -> None:
fset = file_set_hash([(isolated_home / "AGENTS.md", False, False)])
grant_consent("claude", isolated_home, fset)
# Gemini consent stored separately.
assert has_consent("gemini", isolated_home, fset) is False
def test_revoke_consent_removes_all_fset_variants(isolated_home: Path) -> None:
f1 = file_set_hash([(isolated_home / "a", False, False)])
f2 = file_set_hash([(isolated_home / "b", True, True)])
grant_consent("claude", isolated_home, f1)
grant_consent("claude", isolated_home, f2)
assert has_consent("claude", isolated_home, f1)
assert has_consent("claude", isolated_home, f2)
revoke_consent("claude", isolated_home)
assert has_consent("claude", isolated_home, f1) is False
assert has_consent("claude", isolated_home, f2) is False
# ---------------------------------------------------------------------------
# Interactive prompt + env overrides.
# ---------------------------------------------------------------------------
def test_env_override_auto_grants(
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(CONSENT_ASSUME_ENV, "1")
buf = io.StringIO()
assert (
prompt_consent_interactive(
"claude", isolated_home, ["install stuff"], stream=buf
)
is True
)
assert "auto-grant" in buf.getvalue()
@pytest.mark.parametrize("env_val", ["true", "YES", "1"])
def test_env_override_accepts_truthy_values(
env_val: str, isolated_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(CONSENT_ASSUME_ENV, env_val)
buf = io.StringIO()
assert (
prompt_consent_interactive("claude", isolated_home, [], stream=buf) is True
)
def test_non_tty_refuses_without_env(
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv(CONSENT_ASSUME_ENV, raising=False)
# Force stdin.isatty to return False.
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
buf = io.StringIO()
assert (
prompt_consent_interactive(
"claude", isolated_home, ["action 1"], stream=buf
)
is False
)
assert CONSENT_ASSUME_ENV in buf.getvalue()
assert "non-interactive" in buf.getvalue()
def test_interactive_yes_grants(
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv(CONSENT_ASSUME_ENV, raising=False)
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda: "y")
buf = io.StringIO()
assert (
prompt_consent_interactive(
"claude", isolated_home, ["action 1"], stream=buf
)
is True
)
def test_interactive_no_declines(
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv(CONSENT_ASSUME_ENV, raising=False)
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda: "n")
buf = io.StringIO()
assert (
prompt_consent_interactive(
"claude", isolated_home, ["action 1"], stream=buf
)
is False
)
def test_interactive_enter_declines(
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Default (bare Enter) should be the safe "no" path."""
monkeypatch.delenv(CONSENT_ASSUME_ENV, raising=False)
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("builtins.input", lambda: "")
buf = io.StringIO()
assert (
prompt_consent_interactive("claude", isolated_home, ["x"], stream=buf)
is False
)
# ---------------------------------------------------------------------------
# XDG + HOME isolation.
# ---------------------------------------------------------------------------
def test_xdg_config_home_honored(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
xdg = tmp_path / "custom_xdg"
xdg.mkdir()
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
# HOME should be irrelevant once XDG is set.
monkeypatch.setenv("HOME", str(tmp_path / "some_other_home"))
p = consent_path()
assert str(xdg) in str(p)
assert p.name == "consent.yaml"
def test_home_isolation_defaults_to_dot_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""With HOME set and XDG_CONFIG_HOME unset, consent lives under HOME/.config."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
p = consent_path()
assert str(tmp_path) in str(p)
assert ".config/perfxpert/consent.yaml" in str(p)
def test_grant_does_not_touch_other_home(
isolated_home: Path,
) -> None:
"""Sanity: grant writes only under the configured cache path."""
fset = file_set_hash([(isolated_home / "x", False, False)])
grant_consent("claude", isolated_home, fset)
path = consent_path()
assert path.is_file()
# Must live under the isolated tmp tree.
assert str(path).startswith(str(isolated_home)), path
def test_consent_file_is_yaml(isolated_home: Path) -> None:
import yaml
fset = file_set_hash([(isolated_home / "x", False, False)])
grant_consent("claude", isolated_home, fset)
data = yaml.safe_load(consent_path().read_text())
assert isinstance(data, dict)
assert len(data) == 1
record = next(iter(data.values()))
assert record["backend"] == "claude"
assert record["cwd_hash"] == cwd_hash(isolated_home)
assert record["file_set_hash"] == fset
def test_corrupt_cache_is_treated_as_empty(
isolated_home: Path,
) -> None:
"""If the YAML file gets corrupted, we should not crash — just
start fresh (the user will re-prompt)."""
p = consent_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(": not yaml\n" * 50)
fset = file_set_hash([(isolated_home / "x", False, False)])
assert has_consent("claude", isolated_home, fset) is False