Files
ObsiGate/tests/test_tools_mutations.py
bruno 634d10cdd4
CI / lint (push) Successful in 1m32s
CI / security (push) Successful in 1m7s
CI / test (push) Successful in 3m6s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 10m48s
fix(ai): creation dossier+fichier en mode agent (BUG-050)
2026-09-16 21:10:38 -04:00

404 lines
16 KiB
Python

# tests/test_tools_mutations.py — Unit tests for the AI tool layer (Phase D)
"""Tests for the mutation tools: create/edit/append/rename/move/delete/restore.
These exercise the shared tool layer end-to-end against the ``TestVault``
fixture (``client``), including confirmation gating, backups and the
per-vault destructive-tools toggle.
"""
from pathlib import Path
import pytest
from backend.tools.api import (
ToolConfirmationRequired,
ToolContext,
ToolError,
ToolPermissionError,
ToolRisk,
call_tool,
get_tool,
)
MUTATION_TOOLS = {
"create_file",
"create_directory",
"edit_file",
"append_to_file",
"rename_file",
"rename_directory",
"move_path",
"replace_in_files",
"delete_file",
"delete_directory",
"restore_backup",
}
WRITE_TOOLS = {"create_file", "create_directory", "edit_file", "append_to_file", "restore_backup"}
DANGEROUS_TOOLS = {
"rename_file",
"rename_directory",
"move_path",
"replace_in_files",
"delete_file",
"delete_directory",
}
def _ctx(vaults=None, **kwargs) -> ToolContext:
user = {"username": "tester", "role": "admin", "vaults": vaults or ["*"]}
kwargs.setdefault("audit_enabled", False)
return ToolContext(user=user, **kwargs)
def _vault_path() -> Path:
from backend.indexer import get_vault_data
return Path(get_vault_data("TestVault")["path"])
# ═══════════════════════════════════════════════════════════════════
# Registry / risk levels
# ═══════════════════════════════════════════════════════════════════
class TestMutationRegistry:
def test_mutation_tools_registered(self):
for name in MUTATION_TOOLS:
assert get_tool(name) is not None, name
@pytest.mark.parametrize("name", sorted(WRITE_TOOLS))
def test_write_risk(self, name):
assert get_tool(name).risk is ToolRisk.WRITE
@pytest.mark.parametrize("name", sorted(DANGEROUS_TOOLS))
def test_dangerous_risk(self, name):
assert get_tool(name).risk is ToolRisk.DANGEROUS
def test_all_mutations_require_confirmation(self):
for name in MUTATION_TOOLS:
assert get_tool(name).requires_confirmation is True
# ═══════════════════════════════════════════════════════════════════
# Confirmation gating
# ═══════════════════════════════════════════════════════════════════
class TestMutationConfirmation:
def test_create_file_requires_confirmation(self, client):
with pytest.raises(ToolConfirmationRequired):
call_tool("create_file", _ctx(), {"vault": "TestVault", "path": "n.md", "content": "x"})
assert not (_vault_path() / "n.md").exists()
def test_delete_file_requires_confirmation(self, client):
with pytest.raises(ToolConfirmationRequired):
call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"})
assert (_vault_path() / "note1.md").exists()
# ═══════════════════════════════════════════════════════════════════
# D1. Creation
# ═══════════════════════════════════════════════════════════════════
class TestCreate:
def test_create_file(self, client):
result = call_tool(
"create_file",
_ctx(),
{"vault": "TestVault", "path": "new/note.md", "content": "# Hi\n"},
confirm=True,
)
assert result.ok
assert (_vault_path() / "new" / "note.md").read_text(encoding="utf-8") == "# Hi\n"
assert result.data["path"] == "new/note.md"
def test_create_file_already_exists(self, client):
with pytest.raises(ToolError) as exc:
call_tool("create_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True)
assert exc.value.code == "already_exists"
def test_create_file_unsupported_extension(self, client):
with pytest.raises(ToolError) as exc:
call_tool(
"create_file",
_ctx(),
{"vault": "TestVault", "path": "binary.exe", "content": "x"},
confirm=True,
)
assert exc.value.code == "unsupported_extension"
def test_create_file_traversal_rejected(self, client):
with pytest.raises(ToolPermissionError):
call_tool(
"create_file",
_ctx(),
{"vault": "TestVault", "path": "../evil.md", "content": "x"},
confirm=True,
)
def test_create_directory(self, client):
result = call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "A/B"}, confirm=True)
assert result.ok
assert (_vault_path() / "A" / "B").is_dir()
def test_create_directory_idempotent(self, client):
"""BUG-050: re-creating an existing folder is a success for the AI layer."""
call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "Idem/Dir"}, confirm=True)
second = call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "Idem/Dir"}, confirm=True)
assert second.ok
assert second.data.get("existed") is True
# ═══════════════════════════════════════════════════════════════════
# D2. Edit / append / rename / move
# ═══════════════════════════════════════════════════════════════════
class TestEditAppend:
def test_edit_file(self, client):
result = call_tool(
"edit_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "content": "# Replaced\n"},
confirm=True,
)
assert result.ok
assert (_vault_path() / "note1.md").read_text(encoding="utf-8") == "# Replaced\n"
def test_edit_file_creates_backup(self, client):
from backend.services.backups import get_backup_dir
call_tool(
"edit_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "content": "# Changed\n"},
confirm=True,
)
backups = list(get_backup_dir("TestVault", "note1.md").glob("note1.md.*.bak"))
assert backups
def test_edit_missing_file(self, client):
with pytest.raises(ToolError) as exc:
call_tool(
"edit_file",
_ctx(),
{"vault": "TestVault", "path": "missing.md", "content": "x"},
confirm=True,
)
assert exc.value.code == "not_found"
def test_append_to_file(self, client):
original = (_vault_path() / "note1.md").read_text(encoding="utf-8")
result = call_tool(
"append_to_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "content": "APPENDED"},
confirm=True,
)
assert result.ok
updated = (_vault_path() / "note1.md").read_text(encoding="utf-8")
assert updated.startswith(original)
assert updated.endswith("APPENDED")
assert result.data["appended"] == len("APPENDED")
class TestRenameMove:
def test_rename_file(self, client):
result = call_tool(
"rename_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "new_name": "note1_renamed.md"},
confirm=True,
)
assert result.ok
assert not (_vault_path() / "note1.md").exists()
assert (_vault_path() / "note1_renamed.md").exists()
assert result.data["new_path"] == "note1_renamed.md"
def test_rename_file_rejects_path_separator(self, client):
with pytest.raises(ToolError) as exc:
call_tool(
"rename_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "new_name": "sub/note1.md"},
confirm=True,
)
assert exc.value.code == "invalid_arguments"
def test_rename_directory(self, client):
call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "OldDir"}, confirm=True)
result = call_tool(
"rename_directory",
_ctx(),
{"vault": "TestVault", "path": "OldDir", "new_name": "NewDir"},
confirm=True,
)
assert result.ok
assert not (_vault_path() / "OldDir").exists()
assert (_vault_path() / "NewDir").is_dir()
def test_move_file(self, client):
result = call_tool(
"move_path",
_ctx(),
{"vault": "TestVault", "source_path": "note1.md", "destination_dir": "Projets"},
confirm=True,
)
assert result.ok
assert result.data["new_path"] == "Projets/note1.md"
assert (_vault_path() / "Projets" / "note1.md").exists()
def test_move_directory(self, client):
result = call_tool(
"move_path",
_ctx(),
{"vault": "TestVault", "source_path": "Projets", "destination_dir": ""},
confirm=True,
)
assert result.data["item_type"] == "directory"
# ═══════════════════════════════════════════════════════════════════
# D3. Find & replace
# ═══════════════════════════════════════════════════════════════════
class TestReplaceInFiles:
def test_dry_run_does_not_write(self, client):
before = (_vault_path() / "note1.md").read_text(encoding="utf-8")
result = call_tool(
"replace_in_files",
_ctx(),
{"find": "Python", "replace": "Rust", "vault": "TestVault"},
confirm=True,
)
assert result.data["dry_run"] is True
assert result.data["total_matches"] >= 1
assert (_vault_path() / "note1.md").read_text(encoding="utf-8") == before
def test_apply_replaces_and_backs_up(self, client):
result = call_tool(
"replace_in_files",
_ctx(),
{
"find": "Python",
"replace": "Rust",
"vault": "TestVault",
"replace_all": True,
"dry_run": False,
},
confirm=True,
)
assert result.data["dry_run"] is False
assert result.data["total_replacements"] >= 1
assert "Rust" in (_vault_path() / "note1.md").read_text(encoding="utf-8")
def test_filters_inaccessible_vaults(self, client):
ctx = ToolContext(user={"username": "limited", "vaults": ["OtherVault"]}, audit_enabled=False)
result = call_tool(
"replace_in_files",
ctx,
{"find": "Python", "replace": "Rust", "vault": "all"},
confirm=True,
)
assert result.data["total_matches"] == 0
# ═══════════════════════════════════════════════════════════════════
# D4. Delete / restore
# ═══════════════════════════════════════════════════════════════════
class TestDeleteRestore:
def test_delete_file_with_backup(self, client):
from backend.services.backups import get_backup_dir
result = call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True)
assert result.ok
assert not (_vault_path() / "note1.md").exists()
assert list(get_backup_dir("TestVault", "note1.md").glob("note1.md.*.bak"))
def test_delete_missing_file(self, client):
with pytest.raises(ToolError) as exc:
call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "missing.md"}, confirm=True)
assert exc.value.code == "not_found"
def test_delete_directory(self, client):
result = call_tool("delete_directory", _ctx(), {"vault": "TestVault", "path": "Projets"}, confirm=True)
assert result.ok
assert result.data["deleted_count"] >= 1
assert not (_vault_path() / "Projets").exists()
def test_restore_backup(self, client):
from backend.services.backups import create_backup
file_path = _vault_path() / "note1.md"
original = file_path.read_text(encoding="utf-8")
backup_path = create_backup(file_path, "TestVault", "note1.md")
assert backup_path is not None
# Change the file directly (no extra backup), then restore the version.
file_path.write_text("# modified\n", encoding="utf-8")
assert file_path.read_text(encoding="utf-8") == "# modified\n"
version = int(backup_path.name.split(".")[-2])
result = call_tool(
"restore_backup",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "version": version},
confirm=True,
)
assert result.ok
assert file_path.read_text(encoding="utf-8") == original
# ═══════════════════════════════════════════════════════════════════
# Per-vault destructive-tools toggle
# ═══════════════════════════════════════════════════════════════════
class TestDestructiveToggle:
def _disable(self, monkeypatch):
import backend.vault_settings as vs
monkeypatch.setattr(vs, "get_vault_setting", lambda name: {"aiDestructiveTools": False})
def test_delete_blocked_when_disabled(self, client, monkeypatch):
self._disable(monkeypatch)
with pytest.raises(ToolPermissionError) as exc:
call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True)
assert exc.value.code == "destructive_tools_disabled"
assert (_vault_path() / "note1.md").exists()
def test_rename_blocked_when_disabled(self, client, monkeypatch):
self._disable(monkeypatch)
with pytest.raises(ToolPermissionError):
call_tool(
"rename_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "new_name": "x.md"},
confirm=True,
)
def test_edit_still_allowed_when_disabled(self, client, monkeypatch):
self._disable(monkeypatch)
result = call_tool(
"edit_file",
_ctx(),
{"vault": "TestVault", "path": "note1.md", "content": "# ok\n"},
confirm=True,
)
assert result.ok
def test_replace_filters_disabled_vault(self, client, monkeypatch):
self._disable(monkeypatch)
result = call_tool(
"replace_in_files",
_ctx(),
{"find": "Python", "replace": "Rust", "vault": "all", "replace_all": True, "dry_run": False},
confirm=True,
)
assert result.data["total_replacements"] == 0