release: public alpha 0.6.0 baseline
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user