import hashlib from pathlib import Path import tempfile import unittest from unittest.mock import patch from console.settings import Settings from console.store import Store from console.native_plans import build_plan from console.native_rpc import handle class NativeRPCTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() root = Path(self.temp.name) source = root / "source" source.mkdir() entry = source / "test.py" entry.write_text("print(1)\n") self.settings = Settings(root / "runtime", [{"id": "original", "path": str(source)}]) self.settings.test_definitions = [{"key": "native", "source_group": "original", "entry": "test.py", "name": "原环境样例"}] self.settings.native_catalog = {"targets": [], "profiles": [{"id": "native", "source_group": "original", "entry": "test.py", "cwd": str(source), "user": "root", "interpreter": "/usr/bin/python3", "parameters": [], "arguments": [], "impact": {"level": "service", "title": "服务切换", "details": ["会停止指定服务"], "requires_confirmation": True}}]} self.script = {"id": "a" * 32, "name": "test.py", "description": "", "kind": "external", "source_group": "original", "source_path": str(entry), "sha256": hashlib.sha256(entry.read_bytes()).hexdigest(), "language": "python", "version": 1, "enabled": True, "status": "ready", "config": {"native_options": {}, "timeout_seconds": 300}} self.store = Store(self.settings) self.store.save_script(self.script) self.plan = build_plan(self.settings, self.script, check_host=False) self.request = {"op": "queue", "script_id": self.script["id"], "request_id": "5b0202b7-851d-48ad-a7b9-dce22f000001", "plan_token": self.plan["plan_token"], "source_ip": "127.0.0.1"} def tearDown(self): self.temp.cleanup() def test_server_requires_explicit_current_confirmation(self): with patch("console.native_rpc.native_plans.build_plan", return_value=self.plan): with self.assertRaises(ValueError):handle(self.settings, self.store, self.request) with self.assertRaises(ValueError):handle(self.settings, self.store, {**self.request, "impact_acknowledged": True, "plan_token": "old"}) self.assertEqual(self.store.runs(), []) run = handle(self.settings, self.store, {**self.request, "impact_acknowledged": True}) self.assertTrue(run["execution"]["impact_acknowledged"]) self.assertEqual(run["execution"]["mode"], "native") def test_retry_returns_original_even_if_plan_later_changed(self): with patch("console.native_rpc.native_plans.build_plan", return_value=self.plan): first = handle(self.settings, self.store, {**self.request, "impact_acknowledged": True}) with patch("console.native_rpc.native_plans.build_plan", side_effect=ValueError("changed")): again = handle(self.settings, self.store, self.request) self.assertEqual(first["id"], again["id"]) self.assertEqual(len(self.store.runs()), 1) def test_failed_preflight_and_unknown_operation_never_queue(self): with patch("console.native_rpc.native_plans.build_plan", return_value={**self.plan, "ready": False}): with self.assertRaises(ValueError):handle(self.settings, self.store, {**self.request, "impact_acknowledged": True}) with self.assertRaises(ValueError):handle(self.settings, self.store, {"op": "exec", "command": "id"}) self.assertEqual(self.store.runs(), []) def test_input_rejected_for_queued_or_noninteractive_run(self): run = self.store.queue(self.script, "sample", "127.0.0.1", execution={"mode": "native", "interactive": True}) with self.assertRaises(ValueError):handle(self.settings, self.store, {"op": "input", "run_id": run["id"], "text": "1"}) with self.assertRaises(ValueError):handle(self.settings, self.store, {"op": "input", "run_id": "../../bad", "text": "1"}) def test_input_channel_rejects_tmux_keys_and_shell_commands(self): for text in ("\x02c", "\x1b", "bash", "1\nid", "echo test", ":new-window"): with self.assertRaisesRegex(ValueError, "控制键"): handle(self.settings, self.store, {"op": "input", "run_id": "a"*32, "text": text})