#!/usr/bin/env python3 import subprocess import sys import tempfile import unittest from pathlib import Path from unittest import mock PROJECT_ROOT = Path(__file__).resolve().parents[1] SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src" KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src" try: import tomli TOML_EXTRA = [str(Path(tomli.__file__).resolve().parent.parent)] except ModuleNotFoundError: TOML_EXTRA = [] sys.path[:0] = [ str(SDK_SRC), str(PROJECT_ROOT / "packages" / "jwebot-official-extensions" / "src"), str(KERNEL_SRC), ] from jwebot_kernel import ( # noqa: E402 ExtensionHostClient, build_release, install_release, sign_release_record, ) from jwebot_sdk import ExtensionResult, ReplyPlan # noqa: E402 from tests.test_jwebot_edge import MODULE, config, event # noqa: E402 PUBLISHER = "test-publisher" def publisher_key(root: Path): private_key = root / "publisher" result = subprocess.run( [ "/usr/bin/ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(private_key), ], capture_output=True, check=False, ) if result.returncode != 0: raise RuntimeError("cannot generate test publisher key") allowed_signers = root / "allowed_signers" allowed_signers.write_text( f"{PUBLISHER} " + private_key.with_suffix(".pub").read_text(encoding="utf-8"), encoding="utf-8", ) return private_key, allowed_signers def install_coin( root: Path, private_key: Path, allowed_signers: Path, ) -> Path: source = PROJECT_ROOT / "extensions" / "jwebot" / "coin" artifact, record_path, _ = build_release(source, root / "coin-dist") signature_path, _ = sign_release_record( record_path, private_key, PUBLISHER, ) state_root = root / "extensions" install_release( artifact, record_path, state_root / "coin", signature=signature_path, allowed_signers=allowed_signers, expected_publisher=PUBLISHER, health_check=lambda release: None, ) return state_root @unittest.skipUnless( Path("/usr/bin/ssh-keygen").is_file(), "/usr/bin/ssh-keygen is unavailable", ) class EdgeHostedExtensionTests(unittest.TestCase): def setup_signed_coin(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) root = Path(temporary.name) private_key, allowed_signers = publisher_key(root) state_root = install_coin(root, private_key, allowed_signers) return root, state_root, allowed_signers @staticmethod def healthy_coin_factory(calls, *, fail_invoke=False): def factory(plugin_id, release_dir): del release_dir def invoke(request, service_handler): calls.append((plugin_id, dict(request))) if request["op"] == "health": return { "status": "ok", "api_version": "1", "plugin_id": "coin", "author": "jwebot", "description": "抛一枚硬币", "help": ["/翻硬币"], "commands": ["/翻硬币"], "service_profiles": ["coin"], } if fail_invoke: raise RuntimeError("host failed") response = service_handler( "coin", "execute", { "command": request["command"], "arguments": request["arguments"], "event": request["event"], }, ) return { "status": "ok", "api_version": "1", "plugin_id": "coin", "result": ExtensionResult( reply=ReplyPlan.text(response["text"]) ).to_mapping(), } return invoke return factory def test_preferred_host_executes_signed_coin_through_service_broker(self): root, state_root, allowed_signers = self.setup_signed_coin() calls = [] application = MODULE.EdgeApplication( config( root, extension_mode="prefer-host", extension_state_root=state_root, extension_allowed_signers=allowed_signers, extension_publisher=PUBLISHER, ), host_invoker_factory=self.healthy_coin_factory(calls), ) self.addCleanup(application.close) response = application.accept(event("host-coin", "@JWeBot /翻硬币")) self.assertEqual(response["route"], "local") self.assertIn("硬币落地", response["text"]) self.assertEqual( [request["op"] for _, request in calls], ["health", "invoke_command"], ) self.assertEqual(application.health()["hosted_extensions"], 1) def test_preferred_host_uses_in_process_for_missing_release(self): root, state_root, allowed_signers = self.setup_signed_coin() application = MODULE.EdgeApplication( config( root, extension_mode="prefer-host", extension_state_root=state_root, extension_allowed_signers=allowed_signers, extension_publisher=PUBLISHER, ), host_invoker_factory=self.healthy_coin_factory([]), ) self.addCleanup(application.close) response = application.accept( event("fallback-dice", "@JWeBot /丢骰子 1d6") ) self.assertEqual(response["route"], "local") self.assertIn("掷出了", response["text"]) def test_unhealthy_host_is_excluded_before_runtime_fallback(self): root, state_root, allowed_signers = self.setup_signed_coin() def factory(plugin_id, release_dir): del plugin_id, release_dir return lambda request, service: {"status": "error"} application = MODULE.EdgeApplication( config( root, extension_mode="prefer-host", extension_state_root=state_root, extension_allowed_signers=allowed_signers, extension_publisher=PUBLISHER, ), host_invoker_factory=factory, ) self.addCleanup(application.close) response = application.accept( event("fallback-coin", "@JWeBot /翻硬币") ) self.assertIn("硬币落地", response["text"]) self.assertEqual(application.health()["hosted_extension_failures"], 1) def test_active_host_failure_does_not_replay_stateful_command(self): root, state_root, allowed_signers = self.setup_signed_coin() application = MODULE.EdgeApplication( config( root, extension_mode="prefer-host", extension_state_root=state_root, extension_allowed_signers=allowed_signers, extension_publisher=PUBLISHER, ), host_invoker_factory=self.healthy_coin_factory( [], fail_invoke=True, ), ) self.addCleanup(application.close) application.extensions.handle = mock.Mock(side_effect=AssertionError) response = application.accept( event("host-failed", "@JWeBot /翻硬币") ) self.assertIn("隔离插件暂时不可用", response["text"]) application.extensions.handle.assert_not_called() self.assertEqual( application.ledger.row("host-failed")["status"], "local_rejected", ) def test_host_only_requires_every_signed_plugin(self): root, state_root, allowed_signers = self.setup_signed_coin() with self.assertRaisesRegex(MODULE.EdgeError, "startup failed"): MODULE.EdgeApplication( config( root, extension_mode="host-only", extension_state_root=state_root, extension_allowed_signers=allowed_signers, extension_publisher=PUBLISHER, ), host_invoker_factory=self.healthy_coin_factory([]), ) def test_preferred_mode_rejects_tampered_installed_release(self): root, state_root, allowed_signers = self.setup_signed_coin() release_dir = (state_root / "coin" / "current").resolve() (release_dir / "README.md").write_text( "tampered\n", encoding="utf-8", ) with self.assertRaisesRegex(MODULE.EdgeError, "startup failed"): MODULE.EdgeApplication( config( root, extension_mode="prefer-host", extension_state_root=state_root, extension_allowed_signers=allowed_signers, extension_publisher=PUBLISHER, extension_fallback_enabled=True, ), host_invoker_factory=self.healthy_coin_factory([]), ) def test_edge_to_real_host_process_to_service_broker_round_trip(self): root, state_root, allowed_signers = self.setup_signed_coin() wrapper = root / "host-wrapper.py" wrapper.write_text( "\n".join( ( "import runpy, sys", "sys.path[:0] = [", f" {str(SDK_SRC)!r},", f" {str(KERNEL_SRC)!r},", *( f" {path!r}," for path in TOML_EXTRA ), " " + repr( str( PROJECT_ROOT / "packages" / "jwebot-extension-host" / "src" ) ) + ",", "]", "runpy.run_module('jwebot_extension_host', run_name='__main__')", ) ) + "\n", encoding="utf-8", ) def factory(plugin_id, release_dir): del plugin_id client = ExtensionHostClient( command=(sys.executable, str(wrapper)), timeout_seconds=5, allowed_signers=allowed_signers, expected_publisher=PUBLISHER, ) return lambda request, service: client.invoke( release_dir, request, service_handler=service, ) application = MODULE.EdgeApplication( config( root, extension_mode="prefer-host", extension_transport="process", extension_state_root=state_root, extension_allowed_signers=allowed_signers, extension_publisher=PUBLISHER, ), host_invoker_factory=factory, ) self.addCleanup(application.close) response = application.accept( event("real-host-coin", "@JWeBot /翻硬币") ) self.assertEqual(response["route"], "local") self.assertIn("硬币落地", response["text"]) self.assertEqual(application.health()["hosted_extensions"], 1) if __name__ == "__main__": unittest.main()