hermes-agent/tests/gateway/test_line_plugin.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

509 lines
19 KiB
Python

"""Tests for the LINE platform adapter plugin.
Covers LINE adapter behavior from the PR review:
1. webhook signature verification (HMAC-SHA256, base64) + tampering rejection
2. inbound chat-id resolution for user / group / room sources
3. three-allowlist gating (users / groups / rooms / allow_all)
4. inbound dedup via webhookEventId
5. RequestCache state machine (PENDING → READY → DELIVERED, ERROR)
6. Markdown stripping with URL preservation + LINE-sized chunking
7. inbound media normalization to gateway message types and MIME metadata
8. send routing: reply token preferred → push fallback → batched at 5/call
9. register() metadata + standalone_send shape
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import base64
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
# Load plugins/platforms/line/adapter.py under plugin_adapter_line so it
# cannot collide with sibling platform-plugin tests in the same xdist worker.
_line = load_plugin_adapter("line")
verify_line_signature = _line.verify_line_signature
strip_markdown_preserving_urls = _line.strip_markdown_preserving_urls
split_for_line = _line.split_for_line
build_postback_button_message = _line.build_postback_button_message
_resolve_chat = _line._resolve_chat
_allowed_for_source = _line._allowed_for_source
_is_system_bypass = _line._is_system_bypass
RequestCache = _line.RequestCache
State = _line.State
LineAdapter = _line.LineAdapter
register = _line.register
check_requirements = _line.check_requirements
validate_config = _line.validate_config
_standalone_send = _line._standalone_send
_env_enablement = _line._env_enablement
_MessageDeduplicator = _line._MessageDeduplicator
# ---------------------------------------------------------------------------
# 1. Signature verification
# ---------------------------------------------------------------------------
class TestSignature:
def _sign(self, body: bytes, secret: str) -> str:
digest = hmac.new(secret.encode(), body, hashlib.sha256).digest()
return base64.b64encode(digest).decode()
def test_wrong_secret_rejected(self):
body = b'{"events": []}'
sig = self._sign(body, "secret")
assert not verify_line_signature(body, sig, "different")
def test_empty_secret_rejected(self):
assert not verify_line_signature(b"x", "AAAA", "")
# ---------------------------------------------------------------------------
# 2. Chat-id / source resolution
# ---------------------------------------------------------------------------
class TestSourceResolution:
def test_user_source(self):
chat_id, ctype = _resolve_chat({"type": "user", "userId": "U123"})
assert chat_id == "U123"
assert ctype == "dm"
def test_group_source(self):
chat_id, ctype = _resolve_chat({"type": "group", "groupId": "C456", "userId": "U123"})
assert chat_id == "C456"
assert ctype == "group"
# ---------------------------------------------------------------------------
# 3. Three-allowlist gating
# ---------------------------------------------------------------------------
class TestAllowlist:
def test_allow_all_short_circuits(self):
for src in [
{"type": "user", "userId": "Ufoo"},
{"type": "group", "groupId": "Cfoo"},
{"type": "room", "roomId": "Rfoo"},
]:
assert _allowed_for_source(src, allow_all=True, user_ids=set(), group_ids=set(), room_ids=set())
def test_user_in_allowlist_passes(self):
src = {"type": "user", "userId": "Uok"}
assert _allowed_for_source(src, allow_all=False, user_ids={"Uok"}, group_ids=set(), room_ids=set())
# ---------------------------------------------------------------------------
# 4. Inbound dedup
# ---------------------------------------------------------------------------
class TestDedup:
def test_first_event_not_duplicate(self):
d = _MessageDeduplicator()
assert not d.is_duplicate("evt1")
# ---------------------------------------------------------------------------
# 5. RequestCache state machine
# ---------------------------------------------------------------------------
class TestRequestCache:
def test_mark_delivered_from_ready(self):
c = RequestCache()
rid = c.register_pending("Uchat")
c.set_ready(rid, "x")
c.mark_delivered(rid)
assert c.get(rid).state is State.DELIVERED
def test_set_ready_on_delivered_is_noop(self):
c = RequestCache()
rid = c.register_pending("Uchat")
c.set_ready(rid, "first")
c.mark_delivered(rid)
c.set_ready(rid, "second")
# DELIVERED is terminal — no further mutation
assert c.get(rid).payload == "first"
assert c.get(rid).state is State.DELIVERED
# ---------------------------------------------------------------------------
# 6. Markdown stripping + chunking
# ---------------------------------------------------------------------------
class TestMarkdownAndChunking:
def test_bold_stripped(self):
assert strip_markdown_preserving_urls("**hello**") == "hello"
def test_italic_stripped(self):
assert strip_markdown_preserving_urls("*hello*") == "hello"
def test_split_long_chunks_at_paragraph_boundary(self):
text = "para1\n\npara2\n\npara3"
chunks = split_for_line(text, max_chars=8)
assert all(len(c) <= 8 for c in chunks), chunks
assert len(chunks) >= 2
def test_split_caps_at_five_chunks(self):
# 1000 paragraphs of 100 chars each — must cap at 5 LINE bubbles.
text = "\n\n".join(["x" * 100 for _ in range(1000)])
chunks = split_for_line(text)
assert len(chunks) <= 5
# ---------------------------------------------------------------------------
# 7. Inbound media normalization
# ---------------------------------------------------------------------------
class TestInboundMedia:
@pytest.fixture
def adapter(self, monkeypatch):
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={
"channel_access_token": "tok",
"channel_secret": "sec",
})
ad = LineAdapter(cfg)
ad._client = MagicMock()
ad._client.fetch_content = AsyncMock(return_value=b"line-bytes")
ad.handle_message = AsyncMock()
return ad
def _event(self, msg_type, **message):
payload = {"type": msg_type, "id": f"{msg_type}-1"}
payload.update(message)
return {
"type": "message",
"replyToken": "reply-token",
"source": {"type": "group", "groupId": "Cline", "userId": "Uline"},
"message": payload,
}
def _captured_event(self, adapter):
adapter.handle_message.assert_awaited_once()
return adapter.handle_message.await_args.args[0]
def test_image_message_uses_photo_type_and_image_mime(self, adapter):
with patch.object(_line, "cache_image_from_bytes", return_value="/cache/image.jpg") as cache:
asyncio.run(adapter._handle_message_event(self._event("image")))
cache.assert_called_once_with(b"line-bytes", ext=".jpg")
event = self._captured_event(adapter)
assert event.message_type is _line.MessageType.PHOTO
assert event.media_urls == ["/cache/image.jpg"]
assert event.media_types == ["image/jpeg"]
# ---------------------------------------------------------------------------
# 8. Send routing (reply -> push fallback, batching, system-bypass)
# ---------------------------------------------------------------------------
class TestSendRouting:
@pytest.fixture
def adapter(self, monkeypatch):
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={
"channel_access_token": "tok",
"channel_secret": "sec",
})
ad = LineAdapter(cfg)
ad._client = MagicMock()
ad._client.reply = AsyncMock()
ad._client.push = AsyncMock()
return ad
def test_system_bypass_recognized(self):
assert _is_system_bypass("⚡ Interrupting current run")
assert _is_system_bypass("⏳ Queued — agent is busy")
assert _is_system_bypass("⏩ Steered toward new task")
assert not _is_system_bypass("Hello world")
assert not _is_system_bypass("")
def test_send_caps_messages_per_call_at_five(self, adapter):
# Build a payload that would naturally split into more than 5 LINE
# bubbles; the chunker should cap at 5 + truncate.
big = "\n\n".join(["x" * 4500 for _ in range(20)])
result = asyncio.run(adapter.send("Uchat", big))
assert result.success
call_kwargs = adapter._client.push.call_args
# call_args is (args, kwargs); for our send the messages are the 2nd positional
sent_messages = call_kwargs.args[1] if call_kwargs.args else call_kwargs.kwargs.get("messages")
# Without args, fall back to inspecting the call shape
if sent_messages is None:
# We invoked client.push(chat_id, messages) — check first batch
sent_messages = adapter._client.push.call_args.args[1]
assert len(sent_messages) <= 5
def test_format_message_strips_markdown(self, adapter):
out = adapter.format_message("**bold** [link](https://x.com)")
assert "**" not in out
assert "https://x.com" in out
# ---------------------------------------------------------------------------
# 9. Register() metadata + plugin entry points
# ---------------------------------------------------------------------------
class TestRegister:
class _FakeCtx:
def __init__(self):
self.kwargs = None
def register_platform(self, **kw):
self.kwargs = kw
def test_register_advertises_required_env(self):
ctx = self._FakeCtx()
register(ctx)
assert set(ctx.kwargs["required_env"]) == {
"LINE_CHANNEL_ACCESS_TOKEN",
"LINE_CHANNEL_SECRET",
}
def test_register_factory_yields_line_adapter(self):
ctx = self._FakeCtx()
register(ctx)
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={
"channel_access_token": "tok",
"channel_secret": "sec",
})
ad = ctx.kwargs["adapter_factory"](cfg)
assert isinstance(ad, LineAdapter)
def test_max_message_length_below_line_per_bubble_limit(self):
ctx = self._FakeCtx()
register(ctx)
# LINE per-bubble limit is 5000; we register 4500 to leave headroom.
assert ctx.kwargs["max_message_length"] <= 5000
class TestEnvEnablement:
def test_returns_none_without_credentials(self, monkeypatch):
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
assert _env_enablement() is None
class TestStandaloneSend:
def test_missing_token_returns_error(self, monkeypatch):
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={})
result = asyncio.run(_standalone_send(cfg, "Uchat", "hi"))
assert "error" in result
class TestPostbackButtonShape:
def test_template_buttons_structure(self):
msg = build_postback_button_message("hi", "Tap me", "rid-1")
assert msg["type"] == "template"
assert msg["template"]["type"] == "buttons"
assert msg["template"]["text"] == "hi"
actions = msg["template"]["actions"]
assert len(actions) == 1
assert actions[0]["type"] == "postback"
data = json.loads(actions[0]["data"])
assert data == {"action": "show_response", "request_id": "rid-1"}
class TestCheckRequirements:
def test_rejects_without_secret(self, monkeypatch):
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
assert not check_requirements()
class TestValidateConfig:
def test_validates_from_extra(self):
from gateway.config import PlatformConfig
cfg = PlatformConfig(
enabled=True,
extra={"channel_access_token": "t", "channel_secret": "s"},
)
assert validate_config(cfg)
class TestAdapterInit:
def test_init_from_config_extra(self, monkeypatch):
for k in ("LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "LINE_PORT"):
monkeypatch.delenv(k, raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(
enabled=True,
extra={
"channel_access_token": "tok",
"channel_secret": "sec",
"port": 7777,
"public_url": "https://x.example.com",
"allowed_users": ["U1", "U2"],
},
)
ad = LineAdapter(cfg)
assert ad.channel_access_token == "tok"
assert ad.channel_secret == "sec"
assert ad.webhook_port == 7777
assert ad.public_base_url == "https://x.example.com"
assert ad.allowed_users == {"U1", "U2"}
# ---------------------------------------------------------------------------
# 9. Inbound message-type classification
# ---------------------------------------------------------------------------
class TestMessageTypeMapping:
"""LINE webhook message types must map to the right normalized
MessageType so the gateway routes media correctly (e.g. voice → STT,
files → document handling). Regression guard for the old code that
referenced the non-existent ``MessageType.IMAGE`` and collapsed every
non-text message onto a single type."""
def test_image_event_not_attributeerror_regression(self):
# The bug: MessageType.IMAGE doesn't exist on the enum.
MessageType = _line.MessageType
assert not hasattr(MessageType, "IMAGE")
# ---------------------------------------------------------------------------
# 10. Dual-stack bind default (NS-603)
# ---------------------------------------------------------------------------
class TestDualStackBind:
"""The LINE webhook server's default bind must serve BOTH IPv4 and IPv6.
Regression guard for the hosted LINE 502 (NS-603): Fly.io 6PN — the
private network the edge router reverse-proxies LINE ingest over — is
IPv6-only (``<app>.internal`` resolves to an ``fdaa:…`` address). The
adapter used to default to ``host="0.0.0.0"`` (IPv4 only), so the
router's dial to ``<app>.internal:8646`` hit an address nothing was
listening on → connection refused → 502 on webhook verification.
Mirrors gateway/platforms/webhook.py's fix (commit d542894ad):
``DEFAULT_HOST = None`` → asyncio binds one socket per address family.
``"::"`` is NOT a valid substitute (bindv6only=1 on Fly machines makes
it IPv6-only, breaking IPv4 loopback health probes).
"""
def _cfg(self, **extra):
from gateway.config import PlatformConfig
base = {"channel_access_token": "tok", "channel_secret": "sec"}
base.update(extra)
return PlatformConfig(enabled=True, extra=base)
def test_empty_host_normalises_to_none(self, monkeypatch):
monkeypatch.delenv("LINE_HOST", raising=False)
ad = LineAdapter(self._cfg(host=""))
assert ad.webhook_host is None
@pytest.mark.asyncio
async def test_default_bind_serves_both_families(self, monkeypatch):
"""Behavioural proof: host=None opens v4 AND v6 listening sockets."""
# Guard: on IPv4-only hosts (CI runners with IPv6 disabled) the
# dual-stack bind legitimately yields no v6 socket — that's the
# environment, not a regression. Probe an actual ::1 bind rather
# than trusting socket.has_ipv6 (compile-time constant).
import socket as _socket
if not _socket.has_ipv6:
pytest.skip("IPv6 not supported by this Python build")
try:
_probe = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM)
try:
_probe.bind(("::1", 0))
finally:
_probe.close()
except OSError:
pytest.skip("IPv6 stack unavailable on this host (cannot bind ::1)")
monkeypatch.delenv("LINE_HOST", raising=False)
ad = LineAdapter(self._cfg(port=0))
ad._client = MagicMock()
ad._client.get_bot_user_id = AsyncMock(return_value="Ubot")
# Skip credential/network preamble — drive the aiohttp bind directly
# the same way connect() does.
from aiohttp import web
ad._app = web.Application()
ad._runner = web.AppRunner(ad._app)
await ad._runner.setup()
site = web.TCPSite(ad._runner, ad.webhook_host, 0)
try:
await site.start()
addrs = list(ad._runner.addresses)
has_v6 = any(len(a) == 4 for a in addrs)
has_v4 = any(len(a) == 2 for a in addrs)
assert has_v4, f"IPv4 bind missing — got {addrs}"
assert has_v6, (
f"IPv6 bind missing (the 6PN reachability bug, NS-603) — got {addrs}"
)
finally:
await ad._runner.cleanup()
class TestMediaPublicUrlGuard:
"""Outbound media requires LINE_PUBLIC_URL whenever the bind host is not
a publicly fetchable address — including the new dual-stack ``None``
default and the legacy wildcard strings."""
def _adapter(self, monkeypatch, **extra):
from gateway.config import PlatformConfig
monkeypatch.delenv("LINE_HOST", raising=False)
monkeypatch.delenv("LINE_PUBLIC_URL", raising=False)
base = {"channel_access_token": "tok", "channel_secret": "sec"}
base.update(extra)
return LineAdapter(PlatformConfig(enabled=True, extra=base))
def test_missing_public_url_false_with_public_base(self, monkeypatch):
ad = self._adapter(monkeypatch, public_url="https://tunnel.example.com")
if not ad.public_base_url:
# Adapter reads env var name LINE_PUBLIC_URL / extra key —
# set directly if the extra key differs.
ad.public_base_url = "https://tunnel.example.com"
assert ad._missing_public_url() is False
def test_send_image_blocked_without_public_url(self, monkeypatch, tmp_path):
ad = self._adapter(monkeypatch)
ad._client = MagicMock()
img = tmp_path / "x.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n123")
result = asyncio.run(ad.send_image_file("Uchat", str(img)))
assert not result.success
assert "LINE_PUBLIC_URL" in (result.error or "")