Runtime-status publication is now queued to the writer thread, so tests that read gateway_state.json right after a production publish must flush_runtime_status() first (the same line test_status.py already gained). test_multiplex_shared_ingress patched the old write_runtime_status seam; the adapter publishes now, so the patch target follows the rename.
138 lines
6.6 KiB
Python
138 lines
6.6 KiB
Python
"""Inbound-port platforms of a SECONDARY profile are served on the default profile's shared listener
|
|
at ``/p/<profile>/<path>`` (gateway/platforms/shared_ingress.py).
|
|
|
|
Invariants: the forwarded request is verified by the NAMED profile's adapter with that profile's
|
|
secret and runs under that profile's runtime scope; the un-prefixed path is untouched; a profile
|
|
without an adapter for the path is a 404 rather than the default's adapter; a shared-listener
|
|
adapter binds no port of its own.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("aiohttp")
|
|
from aiohttp import web # noqa: E402
|
|
from aiohttp.test_utils import TestClient, TestServer # noqa: E402
|
|
|
|
from gateway.config import GatewayConfig, Platform, PlatformConfig # noqa: E402
|
|
|
|
|
|
def _line_sig(body: bytes, secret: str) -> str:
|
|
return base64.b64encode(hmac.new(secret.encode(), body, hashlib.sha256).digest()).decode()
|
|
|
|
|
|
class _Runner:
|
|
"""Just enough GatewayRunner for shared_ingress: the served-profile adapter map."""
|
|
|
|
def __init__(self, adapters: dict[str, dict[Platform, Any]]):
|
|
self.config = GatewayConfig(multiplex_profiles=True)
|
|
self._profile_adapters = adapters
|
|
self.adapters: dict = {}
|
|
|
|
|
|
def _line_adapter(secret: str, profile: str):
|
|
from plugins.platforms.line.adapter import LineAdapter
|
|
adapter = LineAdapter(PlatformConfig(enabled=True, extra={
|
|
"channel_access_token": f"tok-{profile}", "channel_secret": secret, "port": 1}))
|
|
adapter._shared_listener_profile = profile
|
|
adapter.set_owner_profile(profile)
|
|
return adapter
|
|
|
|
|
|
async def _publish_line(adapter, runner) -> list[tuple[str, Path]]:
|
|
"""Wire the LINE webhook app the way ``connect()`` does, without the LINE API or a bind, and
|
|
record the HERMES_HOME the handler ran under."""
|
|
from gateway.platforms.shared_ingress import bind_listener
|
|
from hermes_constants import get_hermes_home
|
|
seen: list[tuple[str, Path]] = []
|
|
adapter.gateway_runner = runner
|
|
|
|
async def dispatch(event):
|
|
seen.append((event.get("type"), Path(get_hermes_home())))
|
|
|
|
adapter._dispatch_event = dispatch
|
|
app = web.Application(client_max_size=1024)
|
|
app.router.add_post(adapter.webhook_path, adapter._handle_webhook)
|
|
bound = await bind_listener(adapter, app, "127.0.0.1", 1, adapter.webhook_path)
|
|
assert bound is None # shared-listener mode never binds
|
|
return seen
|
|
|
|
|
|
@pytest.fixture
|
|
def mux_home(tmp_path, monkeypatch):
|
|
root = tmp_path / "hermes"
|
|
for name in ("coder", "ops"):
|
|
(root / "profiles" / name).mkdir(parents=True)
|
|
(root / "profiles" / name / ".env").write_text(f"LINE_CHANNEL_SECRET=secret-{name}\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
import hermes_constants
|
|
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
from agent import secret_scope
|
|
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profiles.profiles_to_serve",
|
|
lambda multiplex: [("default", root), ("coder", root / "profiles" / "coder"), ("ops", root / "profiles" / "ops")])
|
|
return root
|
|
|
|
|
|
async def _shared_listener(runner) -> TestClient:
|
|
"""The default profile's listener as the webhook adapter builds it: bare routes + /p/ forwarding."""
|
|
from gateway.platforms.webhook import WebhookAdapter
|
|
listener = WebhookAdapter(PlatformConfig(enabled=True, extra={"port": 1, "routes": {}}))
|
|
listener.gateway_runner = runner
|
|
app = web.Application()
|
|
app.router.add_post("/webhooks/{route_name}", listener._handle_webhook)
|
|
app.router.add_post("/p/{profile}/webhooks/{route_name}", listener._handle_webhook)
|
|
app.router.add_route("*", "/p/{profile}/{tail:.*}", listener._handle_profile_ingress)
|
|
return TestClient(TestServer(app))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefixed_line_webhook_is_verified_by_the_named_profiles_secret_under_its_scope(mux_home):
|
|
coder, ops = _line_adapter("secret-coder", "coder"), _line_adapter("secret-ops", "ops")
|
|
runner = _Runner({"coder": {Platform("line"): coder}, "ops": {Platform("line"): ops}})
|
|
coder_seen, ops_seen = await _publish_line(coder, runner), await _publish_line(ops, runner)
|
|
body = b'{"events":[{"type":"probe"}]}'
|
|
async with await _shared_listener(runner) as client:
|
|
ok = await client.post("/p/coder/line/webhook", data=body,
|
|
headers={"X-Line-Signature": _line_sig(body, "secret-coder")})
|
|
assert ok.status == 200
|
|
# ops' secret is rejected at coder's URL — the adapter is per profile, so is the secret.
|
|
wrong = await client.post("/p/coder/line/webhook", data=body,
|
|
headers={"X-Line-Signature": _line_sig(body, "secret-ops")})
|
|
assert wrong.status == 401
|
|
# The un-prefixed path keeps serving only the default profile's own routes.
|
|
bare = await client.post("/line/webhook", data=body,
|
|
headers={"X-Line-Signature": _line_sig(body, "secret-coder")})
|
|
assert bare.status == 404
|
|
# An unserved profile, and a served profile without that platform, are 404 — never another adapter.
|
|
assert (await client.post("/p/nope/line/webhook", data=body)).status == 404
|
|
runner._profile_adapters["ops"] = {}
|
|
assert (await client.post("/p/ops/line/webhook", data=body,
|
|
headers={"X-Line-Signature": _line_sig(body, "secret-ops")})).status == 404
|
|
assert [t for t, _ in coder_seen] == ["probe"] and ops_seen == []
|
|
assert coder_seen[0][1] == mux_home / "profiles" / "coder"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shared_listener_adapter_records_its_public_ingress_url(mux_home, monkeypatch):
|
|
"""Runtime status carries the /p/<profile>/ URL so `gateway status` / the dashboard can show it."""
|
|
writes: list[dict] = []
|
|
monkeypatch.setattr("gateway.status.publish_runtime_status", lambda **kw: writes.append(kw))
|
|
coder = _line_adapter("secret-coder", "coder")
|
|
coder._runtime_status_platform_key = "coder:line"
|
|
runner = _Runner({"coder": {Platform("line"): coder}})
|
|
runner.adapters = {Platform.API_SERVER: type("L", (), {"_host": "0.0.0.0", "_port": 8642})()}
|
|
await _publish_line(coder, runner)
|
|
assert coder._shared_ingress_url == "http://127.0.0.1:8642/p/coder/line/webhook"
|
|
assert {"platform": "coder:line", "ingress_url": coder._shared_ingress_url} == {
|
|
k: v for k, v in writes[-1].items() if k in ("platform", "ingress_url")}
|
|
assert coder._media_url("tok", "a.png").startswith("http://127.0.0.1:8642/p/coder/line/media/")
|