Files
JWeBot/tests/test_edge_hosted_extensions.py
T

352 lines
12 KiB
Python

#!/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()