Files
agr-assistent/tests/test_commands_actions.py
T
mrleo1nidandClaude Opus 5 04029f3750 Этап 8: настраиваемые команды
- commands.yaml с рабочими примерами: медиа, громкость, поиск, папки, блокировка;
  перечитывается автоматически, ошибки не ломают остальные команды
- Действия run / open / http / keys; вывод и ответы возвращаются модели
- Вызов через модель (tool calling) и мгновенно по точным фразам, в том числе с параметрами
- Подтверждение «да/нет» для опасных команд, после голосового вопроса микрофон включается сам
- Безопасность: запуск без оболочки, защита аргументов cmd/PowerShell/.bat,
  переменные окружения раскрываются только в шаблоне
- Вкладка «Команды» в настройках
- Тесты разбора, действий, фраз и полных сценариев через Assistant

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:38:57 +03:00

219 lines
7.8 KiB
Python

import json
import sys
import threading
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
import pytest
from agr_assistent.commands import actions
from agr_assistent.commands.model import parse_command
def _command(action: dict[str, Any], parameters: dict[str, Any] | None = None): # type: ignore[no-untyped-def]
return parse_command(
{"name": "test", "description": "Тест", "parameters": parameters, "action": action}
)
def test_substitution_encodes_urls_and_keeps_json_types() -> None:
values = {"query": "кофе & чай", "level": 30, "on": True}
assert actions.substitute("q={query}", values, url_encode=True) == (
"q=%D0%BA%D0%BE%D1%84%D0%B5%20%26%20%D1%87%D0%B0%D0%B9"
)
assert actions.substitute("{missing}-{level}", values) == "-30"
assert actions.substitute_json(
{"level": "{level}", "text": "уровень {level}", "flags": ["{on}"]}, values
) == {"level": 30, "text": "уровень 30", "flags": [True]}
def test_parameter_values_cannot_reach_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_SECRET_TOKEN", "секрет")
values = {"value": "${TEST_SECRET_TOKEN}"}
assert actions.substitute_json("Bearer ${TEST_SECRET_TOKEN} {value}", values) == (
"Bearer секрет ${TEST_SECRET_TOKEN}"
)
def test_run_waits_and_returns_output() -> None:
command = _command(
{
"type": "run",
"program": sys.executable,
"args": ["-c", "import sys; print('привет', sys.argv[1])", "{name}"],
"wait": True,
},
{"name": {"type": "string"}},
)
outcome = actions.execute(command, {"name": "мир"})
assert outcome.ok
assert "привет мир" in outcome.content
def test_run_reports_exit_code_and_timeout() -> None:
failing = _command(
{"type": "run", "program": sys.executable, "args": ["-c", "raise SystemExit(3)"], "wait": True}
)
slow = _command(
{
"type": "run",
"program": sys.executable,
"args": ["-c", "import time; time.sleep(5)"],
"wait": True,
"timeout_seconds": 0.5,
}
)
failed = actions.execute(failing, {})
assert not failed.ok and "Код завершения 3" in failed.content
timed_out = actions.execute(slow, {})
assert not timed_out.ok and "не завершилась" in timed_out.content
assert not actions.execute(_command({"type": "run", "program": "no-such-program-xyz"}), {}).ok
@pytest.mark.skipif(sys.platform != "win32", reason="bat-файлы есть только в Windows")
def test_batch_file_rejects_cmd_metacharacters(tmp_path: Path) -> None:
marker = tmp_path / "ran.txt"
script = tmp_path / "echo.bat"
script.write_text(f'@echo %1 > "{marker}"\n', encoding="utf-8")
command = _command(
{"type": "run", "program": str(script), "args": ["{text}"], "wait": True},
{"text": {"type": "string"}},
)
refused = actions.execute(command, {"text": "hi & calc"})
assert not refused.ok and "недопустимые символы" in refused.content
assert not marker.exists()
assert actions.execute(command, {"text": "hello"}).ok
assert marker.exists()
@pytest.mark.skipif(sys.platform != "win32", reason="cmd и powershell есть только в Windows")
def test_shell_interpreters_reject_injection_but_run_plain_values() -> None:
cmd = _command(
{"type": "run", "program": "cmd", "args": ["/c", "echo", "{text}"], "wait": True},
{"text": {"type": "string"}},
)
powershell = _command(
{
"type": "run",
"program": "powershell",
"args": ["-NoProfile", "-Command", "Write-Output '{text}'"],
"wait": True,
},
{"text": {"type": "string"}},
)
assert not actions.execute(cmd, {"text": "hi & calc"}).ok
assert not actions.execute(powershell, {"text": "x'; Remove-Item C:\\temp"}).ok
echoed = actions.execute(cmd, {"text": "Привет"})
assert echoed.ok and "Привет" in echoed.content # вывод cmd в OEM-кодировке декодируется
written = actions.execute(powershell, {"text": "Мир"})
assert written.ok and "Мир" in written.content
def test_open_uses_encoded_url(monkeypatch: pytest.MonkeyPatch) -> None:
opened: list[str] = []
monkeypatch.setattr(actions, "open_target", opened.append)
command = _command(
{"type": "open", "target": "https://example.com/search?q={query}"},
{"query": {"type": "string"}},
)
assert actions.execute(command, {"query": "a b"}).ok
assert opened == ["https://example.com/search?q=a%20b"]
@pytest.fixture
def http_server() -> Iterator[tuple[str, list[dict[str, Any]]]]:
received: list[dict[str, Any]] = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: object) -> None:
pass
def _respond(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
received.append(
{
"method": self.command,
"path": self.path,
"auth": self.headers.get("Authorization"),
"body": json.loads(self.rfile.read(length)) if length else None,
}
)
status = 500 if self.path.startswith("/fail") else 200
payload = json.dumps({"state": "ok"}).encode()
self.send_response(status)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
do_GET = do_POST = _respond
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}", received
server.shutdown()
server.server_close()
def test_http_sends_json_and_secret_headers(
http_server: tuple[str, list[dict[str, Any]]], monkeypatch: pytest.MonkeyPatch
) -> None:
base_url, received = http_server
monkeypatch.setenv("TEST_HA_TOKEN", "token123")
command = _command(
{
"type": "http",
"method": "POST",
"url": base_url + "/light/turn_{state}",
"headers": {"Authorization": "Bearer ${TEST_HA_TOKEN}"},
"json": {"entity_id": "light.room", "brightness": "{level}"},
},
{"state": {"type": "string", "enum": ["on", "off"]}, "level": {"type": "integer"}},
)
outcome = actions.execute(command, {"state": "on", "level": 80})
assert outcome.ok and "HTTP 200" in outcome.content
assert received == [
{
"method": "POST",
"path": "/light/turn_on",
"auth": "Bearer token123",
"body": {"entity_id": "light.room", "brightness": 80},
}
]
assert "token123" not in outcome.content + outcome.display
def test_http_error_status_is_failure(http_server: tuple[str, list[dict[str, Any]]]) -> None:
base_url, _received = http_server
outcome = actions.execute(_command({"type": "http", "url": base_url + "/fail"}), {})
assert not outcome.ok and "HTTP 500" in outcome.content
assert not actions.execute(_command({"type": "http", "url": "http://127.0.0.1:9/"}), {}).ok
@pytest.mark.skipif(sys.platform != "win32", reason="клавиши эмулируются только в Windows")
def test_keys_repeat(monkeypatch: pytest.MonkeyPatch) -> None:
pressed: list[list[int]] = []
monkeypatch.setattr(actions, "send_keys", pressed.append)
command = _command(
{"type": "keys", "keys": "volume_up", "repeat": "{steps}"},
{"steps": {"type": "integer", "default": 3}},
)
assert actions.execute(command, {}).ok
assert pressed == [[0xAF]] * 3