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
303 lines
9.8 KiB
Python
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
|