352 lines
12 KiB
Python
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()
|