251 lines
7.4 KiB
Python
251 lines
7.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cursor_support import (
|
|
AGENT_ID,
|
|
prepare_fixture,
|
|
symlink_or_skip,
|
|
write_json,
|
|
write_text,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"runtime_name",
|
|
[
|
|
"guard.py",
|
|
"hook.py",
|
|
"private.key",
|
|
"chain-state.json",
|
|
"status-cache.json",
|
|
"error.log",
|
|
],
|
|
)
|
|
def test_orphan_runtime_artifacts_are_rejected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
runtime_name: str,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
artifact = fixture.agent_dir / runtime_name
|
|
write_text(artifact, "orphan\n")
|
|
|
|
with pytest.raises(ValueError, match="identity cannot be verified"):
|
|
fixture.install()
|
|
|
|
assert artifact.read_text(encoding="utf-8") == "orphan\n"
|
|
assert fixture.config_path.exists() is False
|
|
|
|
|
|
def test_mismatched_runtime_identity_is_rejected_before_writes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
write_json(
|
|
fixture.runtime_config_path,
|
|
{"agent_id": "another-agent", "agent_name": "cursor"},
|
|
)
|
|
original = fixture.runtime_config_path.read_text(encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError, match="identity does not match"):
|
|
fixture.install()
|
|
|
|
assert fixture.runtime_config_path.read_text(encoding="utf-8") == original
|
|
assert fixture.config_path.exists() is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"runtime_name",
|
|
[
|
|
"config.json",
|
|
"private.key",
|
|
"guard.py",
|
|
"hook.py",
|
|
"chain-state.json",
|
|
"status-cache.json",
|
|
"error.log",
|
|
],
|
|
)
|
|
def test_symbolic_link_runtime_is_rejected_before_config_writes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
runtime_name: str,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
target = fixture.home_dir / f"{runtime_name}.target"
|
|
source = (
|
|
json.dumps({"agent_id": AGENT_ID, "agent_name": "cursor"})
|
|
if runtime_name == "config.json"
|
|
else "target\n"
|
|
)
|
|
write_text(target, source)
|
|
runtime_path = fixture.agent_dir / runtime_name
|
|
symlink_or_skip(target, runtime_path)
|
|
|
|
with pytest.raises(OSError, match="physical file"):
|
|
fixture.install()
|
|
|
|
assert target.read_text(encoding="utf-8") == source
|
|
assert fixture.config_path.exists() is False
|
|
|
|
|
|
def test_symbolic_link_config_is_rejected_and_target_is_preserved(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
target = fixture.home_dir / "cursor-hooks.target.json"
|
|
original = '{"version":1,"hooks":{}}\n'
|
|
write_text(target, original)
|
|
fixture.config_path.parent.mkdir(parents=True)
|
|
symlink_or_skip(target, fixture.config_path)
|
|
|
|
with pytest.raises(OSError, match="physical file"):
|
|
fixture.install()
|
|
|
|
assert target.read_text(encoding="utf-8") == original
|
|
assert fixture.config_path.is_symlink()
|
|
|
|
|
|
def test_symbolic_link_config_directory_is_rejected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
target = fixture.home_dir / "cursor-target"
|
|
target.mkdir()
|
|
symlink_or_skip(target, fixture.config_path.parent, directory=True)
|
|
|
|
with pytest.raises(OSError, match="not a physical directory"):
|
|
fixture.install()
|
|
|
|
assert list(target.iterdir()) == []
|
|
|
|
|
|
def test_symbolic_link_agent_directory_is_rejected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.agent_dir.rmdir()
|
|
target = fixture.home_dir / "agent-target"
|
|
target.mkdir()
|
|
symlink_or_skip(target, fixture.agent_dir, directory=True)
|
|
|
|
with pytest.raises(OSError, match="not a physical directory"):
|
|
fixture.install()
|
|
|
|
assert fixture.config_path.exists() is False
|
|
assert list(target.iterdir()) == []
|
|
|
|
|
|
def test_symbolic_link_runtime_root_is_rejected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.agent_dir.rmdir()
|
|
fixture.agent_dir.parent.rmdir()
|
|
target = fixture.home_dir / "runtime-target"
|
|
(target / AGENT_ID).mkdir(parents=True)
|
|
symlink_or_skip(target, fixture.agent_dir.parent, directory=True)
|
|
|
|
with pytest.raises(OSError, match="not a physical directory"):
|
|
fixture.install()
|
|
|
|
assert fixture.config_path.exists() is False
|
|
assert list((target / AGENT_ID).iterdir()) == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"runtime_name",
|
|
["config.json", "private.key", "guard.py", "hook.py"],
|
|
)
|
|
def test_status_rejects_symbolic_link_runtime_files(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
runtime_name: str,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.install()
|
|
runtime_path = fixture.agent_dir / runtime_name
|
|
target = fixture.home_dir / f"status-{runtime_name}.target"
|
|
target.write_bytes(runtime_path.read_bytes())
|
|
runtime_path.unlink()
|
|
symlink_or_skip(target, runtime_path)
|
|
|
|
with pytest.raises(OSError, match="physical file"):
|
|
fixture.plugin.status()
|
|
|
|
|
|
def test_transaction_rolls_back_all_files_when_config_commit_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
existing_config={"version": 1, "hooks": {"sessionStart": []}},
|
|
)
|
|
original = fixture.config_path.read_text(encoding="utf-8")
|
|
real_replace = os.replace
|
|
failed = False
|
|
|
|
def fail_config_once(source: str, destination: str) -> None:
|
|
nonlocal failed
|
|
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
|
failed = True
|
|
raise OSError("injected config commit failure")
|
|
real_replace(source, destination)
|
|
|
|
monkeypatch.setattr(os, "replace", fail_config_once)
|
|
|
|
with pytest.raises(OSError, match="injected config commit failure"):
|
|
fixture.install()
|
|
|
|
assert fixture.config_path.read_text(encoding="utf-8") == original
|
|
for path in (
|
|
fixture.guard_path,
|
|
fixture.hook_path,
|
|
fixture.runtime_config_path,
|
|
fixture.private_key_path,
|
|
):
|
|
assert path.exists() is False
|
|
assert not list(fixture.home_dir.rglob("*.tmp"))
|
|
assert not list(fixture.home_dir.rglob("*.rollback"))
|
|
|
|
|
|
def test_transaction_detects_concurrent_config_change(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from elydora.plugins._transaction import write_changes
|
|
from elydora.plugins.cursor_contract import render_document
|
|
from elydora.plugins.cursor_io import read_document, rendered_change
|
|
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
existing_config={
|
|
"version": 1,
|
|
"hooks": {"sessionStart": [{"command": "original"}]},
|
|
},
|
|
)
|
|
document = read_document()
|
|
change = rendered_change(render_document(document, {}))
|
|
assert change is not None
|
|
concurrent = '{"version":1,"hooks":{"sessionStart":[]}}\n'
|
|
fixture.config_path.write_text(concurrent, encoding="utf-8")
|
|
|
|
with pytest.raises(OSError, match="changed during installation"):
|
|
write_changes([change], "Update Cursor hooks")
|
|
|
|
assert fixture.config_path.read_text(encoding="utf-8") == concurrent
|
|
assert not list(fixture.config_path.parent.glob("*.tmp"))
|