239 lines
8.0 KiB
Python
239 lines
8.0 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from elydora.plugins import _transaction, copilot
|
|
from elydora.plugins.copilot_installation import prepare_copilot_installation
|
|
from elydora.plugins.copilot_io import read_sources
|
|
|
|
from copilot_support import (
|
|
AGENT_ID,
|
|
assert_runtime_absent,
|
|
legacy_managed_config,
|
|
prepare_fixture,
|
|
write_json_or_text,
|
|
)
|
|
|
|
|
|
def test_install_rolls_back_runtime_hooks_and_legacy_after_late_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
user_config={"version": 1, "hooks": {"notification": []}},
|
|
)
|
|
write_json_or_text(fixture.legacy_path, legacy_managed_config(fixture))
|
|
user_before = fixture.config_path.read_text(encoding="utf-8")
|
|
legacy_before = fixture.legacy_path.read_text(encoding="utf-8")
|
|
original_commit = _transaction._commit
|
|
commits = 0
|
|
|
|
def fail_after_fifth_commit(staged: Any) -> None:
|
|
nonlocal commits
|
|
original_commit(staged)
|
|
commits += 1
|
|
if commits == 5:
|
|
raise OSError("injected late commit failure")
|
|
|
|
monkeypatch.setattr(_transaction, "_commit", fail_after_fifth_commit)
|
|
with pytest.raises(OSError, match="injected late commit failure"):
|
|
fixture.plugin.install(fixture.config)
|
|
|
|
assert fixture.config_path.read_text(encoding="utf-8") == user_before
|
|
assert fixture.legacy_path.read_text(encoding="utf-8") == legacy_before
|
|
assert_runtime_absent(fixture)
|
|
|
|
|
|
def test_install_detects_concurrent_hook_replacement_before_its_commit(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
user_config={"version": 1, "hooks": {"notification": []}},
|
|
)
|
|
concurrent = '{"version":1,"hooks":{"notification":[]},"external":true}\n'
|
|
original_commit = _transaction._commit
|
|
commits = 0
|
|
|
|
def replace_after_first_commit(staged: Any) -> None:
|
|
nonlocal commits
|
|
original_commit(staged)
|
|
commits += 1
|
|
if commits == 1:
|
|
fixture.config_path.write_text(concurrent, encoding="utf-8")
|
|
|
|
monkeypatch.setattr(_transaction, "_commit", replace_after_first_commit)
|
|
with pytest.raises(OSError, match="changed during installation"):
|
|
fixture.plugin.install(fixture.config)
|
|
|
|
assert fixture.config_path.read_text(encoding="utf-8") == concurrent
|
|
assert_runtime_absent(fixture)
|
|
|
|
|
|
def test_install_rolls_back_when_effective_settings_change_mid_transaction(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
user_settings={"disableAllHooks": False},
|
|
)
|
|
original_commit = _transaction._commit
|
|
commits = 0
|
|
|
|
def disable_after_third_commit(staged: Any) -> None:
|
|
nonlocal commits
|
|
original_commit(staged)
|
|
commits += 1
|
|
if commits == 3:
|
|
write_json_or_text(
|
|
fixture.user_settings_path,
|
|
{"disableAllHooks": True},
|
|
)
|
|
|
|
monkeypatch.setattr(_transaction, "_commit", disable_after_third_commit)
|
|
with pytest.raises(OSError, match="user settings changed"):
|
|
fixture.plugin.install(fixture.config)
|
|
|
|
assert json.loads(
|
|
fixture.user_settings_path.read_text(encoding="utf-8")
|
|
)["disableAllHooks"] is True
|
|
assert fixture.config_path.exists() is False
|
|
assert_runtime_absent(fixture)
|
|
|
|
|
|
def test_prepare_rejects_same_content_stale_hook_snapshot(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
user_config={"version": 1, "hooks": {"notification": []}},
|
|
)
|
|
sources = read_sources()
|
|
paths = copilot.preflight_copilot_installation(fixture.config, sources)
|
|
rendered = copilot._render_installation(
|
|
sources,
|
|
paths.guard_path,
|
|
paths.audit_path,
|
|
)
|
|
original = fixture.config_path.read_text(encoding="utf-8")
|
|
replacement = fixture.config_path.with_name("replacement.json")
|
|
replacement.write_text(original, encoding="utf-8")
|
|
os.replace(replacement, fixture.config_path)
|
|
|
|
with pytest.raises(OSError, match="changed before staging"):
|
|
prepare_copilot_installation(fixture.config, sources, rendered)
|
|
assert_runtime_absent(fixture)
|
|
|
|
|
|
def test_install_preserves_orphaned_runtime_without_verifiable_identity(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.agent_dir.mkdir(parents=True)
|
|
fixture.hook_path.write_text("# orphaned\n", encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError, match="cannot be verified"):
|
|
fixture.plugin.install(fixture.config)
|
|
|
|
assert fixture.hook_path.read_text(encoding="utf-8") == "# orphaned\n"
|
|
assert fixture.config_path.exists() is False
|
|
assert fixture.guard_path.exists() is False
|
|
assert fixture.runtime_config_path.exists() is False
|
|
assert fixture.private_key_path.exists() is False
|
|
|
|
|
|
def _symlink_or_skip(
|
|
target: Path,
|
|
link: Path,
|
|
*,
|
|
directory: bool,
|
|
) -> None:
|
|
try:
|
|
link.parent.mkdir(parents=True, exist_ok=True)
|
|
os.symlink(target, link, target_is_directory=directory)
|
|
except (NotImplementedError, OSError) as error:
|
|
pytest.skip(f"symbolic links unavailable: {error}")
|
|
|
|
|
|
@pytest.mark.parametrize("location", ["home", "hook", "runtime"])
|
|
def test_install_rejects_linked_hook_and_runtime_paths(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
location: str,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
if location == "home":
|
|
target = tmp_path / "copilot-target"
|
|
target.mkdir()
|
|
linked = tmp_path / "linked-copilot-home"
|
|
_symlink_or_skip(target, linked, directory=True)
|
|
monkeypatch.setenv("COPILOT_HOME", str(linked))
|
|
elif location == "hook":
|
|
target = tmp_path / "hook-target.json"
|
|
write_json_or_text(target, {"version": 1, "hooks": {}})
|
|
_symlink_or_skip(target, fixture.config_path, directory=False)
|
|
else:
|
|
target = tmp_path / "runtime-target"
|
|
target.mkdir()
|
|
fixture.agent_dir.parent.mkdir(parents=True)
|
|
_symlink_or_skip(target, fixture.agent_dir, directory=True)
|
|
|
|
with pytest.raises(OSError, match="physical"):
|
|
fixture.plugin.install(fixture.config)
|
|
|
|
|
|
def test_uninstall_restores_both_hook_documents_when_second_commit_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.plugin.install(fixture.config)
|
|
managed = fixture.config_path.read_text(encoding="utf-8")
|
|
fixture.legacy_path.parent.mkdir(parents=True, exist_ok=True)
|
|
fixture.legacy_path.write_text(managed, encoding="utf-8")
|
|
user_before = fixture.config_path.read_text(encoding="utf-8")
|
|
legacy_before = fixture.legacy_path.read_text(encoding="utf-8")
|
|
original_commit = _transaction._commit
|
|
commits = 0
|
|
|
|
def fail_after_second_commit(staged: Any) -> None:
|
|
nonlocal commits
|
|
original_commit(staged)
|
|
commits += 1
|
|
if commits == 2:
|
|
raise OSError("injected uninstall failure")
|
|
|
|
monkeypatch.setattr(_transaction, "_commit", fail_after_second_commit)
|
|
with pytest.raises(OSError, match="injected uninstall failure"):
|
|
fixture.plugin.uninstall(AGENT_ID)
|
|
|
|
assert fixture.config_path.read_text(encoding="utf-8") == user_before
|
|
assert fixture.legacy_path.read_text(encoding="utf-8") == legacy_before
|
|
|
|
|
|
def test_installation_leaves_no_transaction_artifacts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.plugin.install(fixture.config)
|
|
for directory in (fixture.agent_dir, fixture.config_path.parent):
|
|
assert all(
|
|
path.suffix not in {".tmp", ".rollback"}
|
|
for path in directory.iterdir()
|
|
)
|