"""Tests for the srs-gate Hermes plugin. Standard library only. Run from the repo root: python -m unittest discover -s hermes-plugin/srs-gate -v All phone digits, ids and the secret below are placeholders, not real values. """ from __future__ import annotations import asyncio import base64 import enum import importlib.util import inspect import json import logging import os import socket import tempfile import threading import time import unittest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from types import SimpleNamespace from unittest import mock HERE = Path(__file__).resolve().parent def _load_plugin(): spec = importlib.util.spec_from_file_location("srs_gate", HERE / "__init__.py") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module gate = _load_plugin() SKIP = {"action": "skip", "reason": "srs-gate"} SECRET = "test-only-secret-not-real-0123456789" PHONE = "910000000001" SENDER = PHONE + "@s.whatsapp.net" CHAT = "100000000000077@lid" MID = "3EB0TESTMSGID01" TEXT = "Shop Zq7\n0012-5\n4567 x 10\ncall " + PHONE SENSITIVE = [SECRET, PHONE, SENDER, CHAT, "100000000000077", "Zq7", "4567 x 10"] class MessageType(enum.Enum): TEXT = "text" LOCATION = "location" PHOTO = "photo" VIDEO = "video" AUDIO = "audio" VOICE = "voice" DOCUMENT = "document" STICKER = "sticker" COMMAND = "command" def make_event(text=TEXT): source = SimpleNamespace( platform="whatsapp", chat_id=CHAT, chat_type="dm", user_id=SENDER, user_id_alt=None, chat_id_alt=None, is_bot=False, message_id=MID, ) return SimpleNamespace( text=text, message_type=MessageType.TEXT, message_id=MID, source=source, media_urls=[], media_types=[], internal=False, edit_revision=None, metadata={}, user_id=None, ) class ExplodingEvent: @property def source(self): raise RuntimeError("boom") @property def internal(self): raise RuntimeError("boom") # ---------------------------------------------------------------- stub srs-api class _Server(ThreadingHTTPServer): daemon_threads = True block_on_close = False def handle_error(self, request, client_address): pass class Stub: """Stands in for srs-api. `script` is a list of actions, one per request; after it runs out, 200. Actions: ("status", code, delay_s) or ("garbage",). """ def __init__(self, script=None): self.requests = [] self.script = list(script or []) self._lock = threading.Lock() stub = self class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_POST(self): length = int(self.headers.get("content-length") or 0) body = self.rfile.read(length) with stub._lock: stub.requests.append( {"method": "POST", "path": self.path, "headers": dict(self.headers), "body": body} ) action = stub.script.pop(0) if stub.script else ("status", 200, 0) if action[0] == "garbage": self.wfile.write(b"this is not http\r\n\r\n") self.close_connection = True return _, code, delay = action if delay: time.sleep(delay) payload = b'{"handled":true,"duplicate":false}' self.send_response(code) if 300 <= code < 400: self.send_header("location", "/elsewhere") self.send_header("content-type", "application/json") self.send_header("content-length", str(len(payload))) self.end_headers() self.wfile.write(payload) do_GET = do_POST self.server = _Server(("127.0.0.1", 0), Handler) self.url = "http://127.0.0.1:%d" % self.server.server_address[1] self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() def stop(self): self.server.shutdown() self.server.server_close() def closed_port_url(): s = socket.socket() s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] s.close() return "http://127.0.0.1:%d" % port class ListHandler(logging.Handler): def __init__(self): super().__init__(level=logging.DEBUG) self.records = [] def emit(self, record): self.records.append(record) # ---------------------------------------------------------------- base class GateCase(unittest.IsolatedAsyncioTestCase): """Fresh stub, config file, fast timings and a log capture for every test. Every test also checks, on teardown, that no log record anywhere holds the text, the secret or a number. """ def setUp(self): self.stub = Stub() self.addCleanup(self.stub.stop) self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) env = mock.patch.dict(os.environ, {"HERMES_HOME": self.tmp.name}) env.start() self.addCleanup(env.stop) self.write_config(url=self.stub.url) for name, value in (("TRIES", 3), ("TRY_TIMEOUT_S", 1.0), ("BACKOFF_S", 0.01), ("OVERALL_TIMEOUT_S", 5.0)): p = mock.patch.object(gate, name, value) p.start() self.addCleanup(p.stop) gate._config_warned = False self.logs = ListHandler() root = logging.getLogger() self._old_level = root.level root.addHandler(self.logs) root.setLevel(logging.DEBUG) self.addCleanup(self._restore_logging) def _restore_logging(self): root = logging.getLogger() root.removeHandler(self.logs) root.setLevel(self._old_level) def tearDown(self): self.assert_no_sensitive_logs() # helpers def write_config(self, url=None, secret=SECRET, extra=""): lines = ["# srs-gate test config"] if url is not None: lines.append("SRS_URL=" + url) if secret is not None: lines.append("SRS_SECRET=" + secret) Path(self.tmp.name, "srs-gate.env").write_text("\n".join(lines) + "\n" + extra, encoding="utf-8") def config_path(self): return Path(self.tmp.name, "srs-gate.env") async def call(self, event): return await gate.on_inbound(event=event, gateway=object(), session_store=object()) def log_text(self): out = [] for r in self.logs.records: out.append(r.getMessage()) out.append(repr(r.args)) if r.exc_info or r.exc_text: out.append(logging.Formatter().formatException(r.exc_info) if r.exc_info else r.exc_text) return "\n".join(out) def assert_no_sensitive_logs(self): text = self.log_text() for needle in SENSITIVE: self.assertNotIn(needle, text, "sensitive value in logs") def gate_messages(self): return [r.getMessage() for r in self.logs.records if r.name == "srs_gate"] # ---------------------------------------------------------------- payload class PayloadTests(GateCase): def test_exact_payload_for_direct_text(self): self.assertEqual( gate._payload(make_event()), {"kind": "text", "message_id": MID, "sender_jid": SENDER, "chat_id": CHAT, "text": TEXT}, ) def test_plain_string_message_type_is_accepted(self): ev = make_event() ev.message_type = "text" self.assertIsNotNone(gate._payload(ev)) async def test_text_arrives_byte_for_byte(self): texts = { "zeros_and_breaks": "0012\n0099\r\n 0001 \n\n0000 - 5\t", "malayalam": "ഷോപ്പ് 0012 - 5", "hundred_lines": "\n".join("%04d - %d" % (i, i % 7 + 1) for i in range(100)), } for name, text in texts.items(): with self.subTest(name): before = len(self.stub.requests) self.assertEqual(await self.call(make_event(text)), SKIP) self.assertEqual(len(self.stub.requests), before + 1) body = json.loads(self.stub.requests[-1]["body"].decode("utf-8")) self.assertEqual(body["text"], text) self.assertEqual(body["kind"], "text") # ---------------------------------------------------------------- filters def _set(path, value): def apply(ev): target = ev parts = path.split(".") for p in parts[:-1]: target = getattr(target, p) setattr(target, parts[-1], value) return apply def _delete(path): def apply(ev): target = ev parts = path.split(".") for p in parts[:-1]: target = getattr(target, p) delattr(target, parts[-1]) return apply NOT_FORWARDED = [ ("group chat_type", _set("source.chat_type", "group")), ("channel chat_type", _set("source.chat_type", "channel")), ("thread chat_type", _set("source.chat_type", "thread")), ("unknown chat_type", _set("source.chat_type", "something-new")), ("chat_type wrong case", _set("source.chat_type", "DM")), ("chat_type None", _set("source.chat_type", None)), ("chat_type missing", _delete("source.chat_type")), ("group chat id", _set("source.chat_id", "120363000000000000@g.us")), ("internal", _set("internal", True)), ("is_bot", _set("source.is_bot", True)), ("edit_revision set", _set("edit_revision", "2")), ("edit_revision int", _set("edit_revision", 1)), ("blank text", _set("text", "")), ("whitespace text", _set("text", " \n\t ")), ("text None", _set("text", None)), ("text not a string", _set("text", 1234)), ("missing message id", _set("message_id", None)), ("empty message id", _set("message_id", "")), ("missing sender", _set("source.user_id", None)), ("missing chat id", _set("source.chat_id", "")), ("no source", _set("source", None)), ("message_type missing", _set("message_type", None)), ] + [ ("message_type " + t.value, _set("message_type", t)) for t in MessageType if t not in (MessageType.TEXT, MessageType.PHOTO) # PhotoTests covers photos ] class FilterTests(GateCase): async def test_not_forwarded_but_still_skipped(self): for name, mutate in NOT_FORWARDED: with self.subTest(name): ev = make_event() mutate(ev) self.assertIsNone(gate._payload(ev)) self.assertEqual(await self.call(ev), SKIP) self.assertEqual(self.stub.requests, []) async def test_photo_placeholder_text_is_not_forwarded(self): ev = make_event("[image received]") ev.message_type = MessageType.PHOTO self.assertEqual(await self.call(ev), SKIP) self.assertEqual(self.stub.requests, []) # ---------------------------------------------------------------- photos JPEG = b"\xff\xd8\xff\xe0 fake image bytes Pq9" JPEG_B64 = base64.b64encode(JPEG).decode("ascii") class PhotoTests(GateCase): def setUp(self): super().setUp() for name, value in (("PHOTO_TRY_TIMEOUT_S", 1.0), ("PHOTO_OVERALL_TIMEOUT_S", 5.0)): p = mock.patch.object(gate, name, value) p.start() self.addCleanup(p.stop) self.cache = Path(self.tmp.name, "cache", "images") self.cache.mkdir(parents=True) def save(self, name="img_1.jpg", data=JPEG): path = self.cache / name path.write_bytes(data) return str(path) def photo_event(self, paths, text="[image received]"): ev = make_event(text) ev.message_type = MessageType.PHOTO ev.media_urls = paths return ev def bodies(self): return [json.loads(r["body"].decode("utf-8")) for r in self.stub.requests] async def test_exact_payload(self): self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP) self.assertEqual( self.bodies(), [ { "kind": "image", "message_id": MID, "sender_jid": SENDER, "chat_id": CHAT, "image_base64": JPEG_B64, "mime_type": "image/jpeg", } ], ) self.assertIn("status=200", self.gate_messages()[-1]) async def test_placeholder_is_never_forwarded_as_text(self): for text in ("[image received]", " [image received]\n", "", None): with self.subTest(text): self.stub.requests.clear() await self.call(self.photo_event([self.save()], text)) self.assertEqual([b["kind"] for b in self.bodies()], ["image"]) async def test_typed_text_follows_the_photo_as_a_typed_message(self): await self.call(self.photo_event([self.save()], "4567 x 10")) bodies = self.bodies() self.assertEqual([b["kind"] for b in bodies], ["image", "text"]) self.assertEqual(bodies[1]["message_id"], MID + "#text") self.assertEqual(bodies[1]["text"], "4567 x 10") async def test_placeholder_is_removed_from_typed_text(self): for text in ("[image received] 4567 x 10", "4567 x 10\n[image received]"): with self.subTest(text): self.stub.requests.clear() await self.call(self.photo_event([self.save()], text)) self.assertEqual(self.bodies()[1]["text"], "4567 x 10") async def test_several_paths_get_their_own_ids_and_text_comes_last(self): paths = [self.save("a.jpg"), self.save("b.jpg"), self.save("c.jpg")] await self.call(self.photo_event(paths, "4567 x 10")) self.assertEqual( [b["message_id"] for b in self.bodies()], [MID, MID + "#2", MID + "#3", MID + "#text"] ) async def test_refused_files_are_not_sent_but_typed_text_is(self): outside = Path(self.tmp.name, "secret.jpg") outside.write_bytes(JPEG) directory = self.cache / "folder" directory.mkdir() big = self.save("big.jpg", b"x" * 11) refused = { "outside the cache": (str(outside), "outside_cache"), "dot dot out of the cache": (str(self.cache / ".." / ".." / "secret.jpg"), "outside_cache"), "an absolute system path": (str(HERE / "__init__.py"), "outside_cache"), "a directory": (str(directory), "not_a_file"), "missing": (str(self.cache / "gone.jpg"), "unreadable"), "too large": (big, "too_large"), } with mock.patch.object(gate, "MAX_PHOTO_BYTES", 10): for name, (path, reason) in refused.items(): with self.subTest(name): self.stub.requests.clear() self.assertEqual(await self.call(self.photo_event([path], "4567 x 10")), SKIP) self.assertEqual([b["kind"] for b in self.bodies()], ["text"]) self.assertIn(reason, " ".join(self.gate_messages())) async def test_a_file_at_the_limit_is_sent(self): with mock.patch.object(gate, "MAX_PHOTO_BYTES", len(JPEG)): await self.call(self.photo_event([self.save()])) self.assertEqual(len(self.stub.requests), 1) async def test_a_symlink_out_of_the_cache_is_refused(self): outside = Path(self.tmp.name, "secret.jpg") outside.write_bytes(JPEG) link = self.cache / "link.jpg" try: link.symlink_to(outside) except OSError: self.skipTest("symlinks are not allowed here") await self.call(self.photo_event([str(link)])) self.assertEqual(self.stub.requests, []) self.assertIn("outside_cache", self.gate_messages()[-1]) async def test_filters_still_apply(self): path = self.save() for name, mutate in ( ("group chat_type", _set("source.chat_type", "group")), ("group chat id", _set("source.chat_id", "120363000000000000@g.us")), ("internal", _set("internal", True)), ("is_bot", _set("source.is_bot", True)), ("edit", _set("edit_revision", "2")), ("no message id", _set("message_id", None)), ("no sender", _set("source.user_id", None)), ("sticker", _set("message_type", MessageType.STICKER)), ("video", _set("message_type", MessageType.VIDEO)), ("document", _set("message_type", MessageType.DOCUMENT)), ): with self.subTest(name): ev = self.photo_event([path], "4567 x 10") mutate(ev) self.assertEqual(await self.call(ev), SKIP) self.assertEqual(self.stub.requests, []) async def test_nothing_usable_sends_nothing(self): for name, urls in (("empty", []), ("none", None), ("a string", "oops"), ("not strings", [1, None, ""])): with self.subTest(name): self.assertEqual(await self.call(self.photo_event(urls)), SKIP) self.assertEqual(self.stub.requests, []) async def test_logs_hold_no_path_bytes_or_text(self): secret_dir = self.cache / "Xy3folder" secret_dir.mkdir() good = secret_dir / "good.jpg" good.write_bytes(JPEG) await self.call(self.photo_event([str(good), str(secret_dir / "gone.jpg")], "4567 x 10")) logged = self.log_text() for needle in ("Xy3folder", "good.jpg", "gone.jpg", JPEG_B64, "Pq9", self.tmp.name): self.assertNotIn(needle, logged) self.assertEqual(len(self.gate_messages()), 3) async def test_a_photo_gets_longer_tries_than_text(self): self.stub.script = [("status", 200, 0.4), ("status", 200, 0)] with mock.patch.object(gate, "TRY_TIMEOUT_S", 0.1), mock.patch.object(gate, "PHOTO_TRY_TIMEOUT_S", 2.0): await self.call(self.photo_event([self.save()])) self.assertEqual(len(self.stub.requests), 1, "the slow upload should not have been retried") async def test_overall_timeout_still_skips(self): self.stub.script = [("status", 200, 0.5)] with mock.patch.object(gate, "PHOTO_OVERALL_TIMEOUT_S", 0.15), mock.patch.object(gate, "TRIES", 1): self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP) self.assertIn("TimeoutError", self.gate_messages()[-1]) async def test_srs_api_down_still_skips(self): self.write_config(url=closed_port_url()) self.assertEqual(await self.call(self.photo_event([self.save()], "4567 x 10")), SKIP) async def test_bad_config_sends_nothing(self): self.write_config(url=self.stub.url, secret=None) self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP) self.assertEqual(self.stub.requests, []) def test_forward_never_raises_even_systemexit(self): cfg = {"SRS_URL": self.stub.url, "SRS_SECRET": SECRET} with mock.patch.object(gate, "_read_photo", side_effect=SystemExit(3)): rows = gate._forward_photo(cfg, gate._photo_work(self.photo_event([self.save()]))) self.assertEqual(rows, [(MID, None, "SystemExit", None)]) async def test_unexpected_errors_still_skip(self): with mock.patch.object(gate, "_forward_photo", side_effect=ValueError("x")): self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP) with mock.patch.object(gate, "_photo_work", side_effect=ValueError("x")): self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP) # ---------------------------------------------------------------- request class RequestTests(GateCase): async def test_headers_method_and_path(self): self.assertEqual(await self.call(make_event()), SKIP) self.assertEqual(len(self.stub.requests), 1) req = self.stub.requests[0] headers = {k.lower(): v for k, v in req["headers"].items()} self.assertEqual(req["method"], "POST") self.assertEqual(req["path"], "/ingest") self.assertEqual(headers["x-srs-secret"], SECRET) self.assertEqual(headers["content-type"], "application/json") async def test_trailing_slash_in_url(self): self.write_config(url=self.stub.url + "/") await self.call(make_event()) self.assertEqual(self.stub.requests[0]["path"], "/ingest") async def test_quoted_config_values(self): self.write_config(url='"%s"' % self.stub.url, secret="'%s'" % SECRET) await self.call(make_event()) headers = {k.lower(): v for k, v in self.stub.requests[0]["headers"].items()} self.assertEqual(headers["x-srs-secret"], SECRET) def test_proxy_environment_is_ignored(self): dead = closed_port_url() with mock.patch.dict(os.environ, {"http_proxy": dead, "https_proxy": dead, "no_proxy": ""}): fresh = _load_plugin() status, error = fresh._post( {"SRS_URL": self.stub.url, "SRS_SECRET": SECRET}, fresh._payload(make_event()) ) self.assertEqual((status, error), (200, None)) self.assertEqual(len(self.stub.requests), 1) async def test_success_log_has_id_and_status(self): await self.call(make_event()) msgs = self.gate_messages() self.assertEqual(len(msgs), 1) self.assertIn("id=" + MID, msgs[0]) self.assertIn("status=200", msgs[0]) # ---------------------------------------------------------------- retries class RetryTests(GateCase): async def test_500_then_200_succeeds_on_second_try(self): self.stub.script = [("status", 500, 0), ("status", 200, 0)] self.assertEqual(await self.call(make_event()), SKIP) self.assertEqual(len(self.stub.requests), 2) self.assertIn("status=200", self.gate_messages()[-1]) async def test_500_three_times_gives_up(self): self.stub.script = [("status", 500, 0)] * 5 self.assertEqual(await self.call(make_event()), SKIP) self.assertEqual(len(self.stub.requests), 3) self.assertIn("status=500", self.gate_messages()[-1]) async def test_4xx_and_3xx_are_not_retried(self): for code in (400, 401, 404, 302): with self.subTest(code): self.stub.requests.clear() self.stub.script = [("status", code, 0), ("status", 200, 0)] self.assertEqual(await self.call(make_event()), SKIP) self.assertEqual(len(self.stub.requests), 1, "retried or followed a redirect") self.assertIn("status=%d" % code, self.gate_messages()[-1]) def test_connection_refused_ends_after_three_tries(self): cfg = {"SRS_URL": closed_port_url(), "SRS_SECRET": SECRET} with mock.patch.object(gate, "TRY_TIMEOUT_S", 0.5), mock.patch.object( gate._OPENER, "open", wraps=gate._OPENER.open ) as opened: status, error = gate._post(cfg, gate._payload(make_event())) self.assertEqual(opened.call_count, 3) self.assertIsNone(status) self.assertIsNotNone(error) async def test_slow_server_hits_per_try_timeout_then_retries(self): self.stub.script = [("status", 200, 0.6), ("status", 200, 0)] with mock.patch.object(gate, "TRY_TIMEOUT_S", 0.2): self.assertEqual(await self.call(make_event()), SKIP) self.assertEqual(len(self.stub.requests), 2) self.assertIn("status=200", self.gate_messages()[-1]) # ---------------------------------------------------------------- fail closed class FailClosedTests(GateCase): async def test_opener_raises(self): with mock.patch.object(gate._OPENER, "open", side_effect=RuntimeError("x")): self.assertEqual(await self.call(make_event()), SKIP) async def test_bad_events(self): for name, ev in (("None", None), ("object", object()), ("exploding", ExplodingEvent()), ("string", "hi")): with self.subTest(name): self.assertEqual(await self.call(ev), SKIP) self.assertEqual(await gate.on_inbound(), SKIP) self.assertEqual(self.stub.requests, []) async def test_bad_config(self): cases = { "missing file": lambda: self.config_path().unlink(), "no secret": lambda: self.write_config(url=self.stub.url, secret=None), "empty secret": lambda: self.write_config(url=self.stub.url, secret=""), "no url": lambda: self.write_config(url=None), "file scheme": lambda: self.write_config(url="file:///etc/passwd"), "no scheme": lambda: self.write_config(url="127.0.0.1:8080"), } for name, breakit in cases.items(): with self.subTest(name): breakit() self.assertEqual(await self.call(make_event()), SKIP) self.assertEqual(self.stub.requests, []) async def test_config_error_logged_once_until_fixed(self): self.write_config(url=self.stub.url, secret=None) await self.call(make_event()) await self.call(make_event()) self.assertEqual(len(self.gate_messages()), 1) self.write_config(url=self.stub.url) await self.call(make_event()) self.write_config(url=self.stub.url, secret=None) await self.call(make_event()) config_warnings = [m for m in self.gate_messages() if "config" in m] self.assertEqual(len(config_warnings), 2) async def test_garbage_reply(self): self.stub.script = [("garbage",)] * 3 self.assertEqual(await self.call(make_event()), SKIP) self.assertGreaterEqual(len(self.stub.requests), 1) async def test_overall_timeout(self): self.stub.script = [("status", 200, 0.5)] with mock.patch.object(gate, "OVERALL_TIMEOUT_S", 0.15), mock.patch.object( gate, "TRIES", 1 ), mock.patch.object(gate, "TRY_TIMEOUT_S", 1.0): started = time.monotonic() self.assertEqual(await self.call(make_event()), SKIP) self.assertLess(time.monotonic() - started, 0.45) self.assertIn("TimeoutError", self.gate_messages()[-1]) def test_post_never_raises_even_systemexit(self): cfg = {"SRS_URL": self.stub.url, "SRS_SECRET": SECRET} with mock.patch.object(gate._OPENER, "open", side_effect=SystemExit(3)): status, error = gate._post(cfg, gate._payload(make_event())) self.assertIsNone(status) self.assertEqual(error, "SystemExit") async def test_systemexit_in_http_call(self): with mock.patch.object(gate._OPENER, "open", side_effect=SystemExit(3)): self.assertEqual(await self.call(make_event()), SKIP) async def test_backstop_catches_systemexit_outside_the_thread(self): with mock.patch.object(gate, "_load_config", side_effect=SystemExit(3)): self.assertEqual(await self.call(make_event()), SKIP) async def test_backstop_catches_unexpected_error(self): with mock.patch.object(gate, "_payload", side_effect=ValueError("x")): self.assertEqual(await self.call(make_event()), SKIP) # ---------------------------------------------------------------- loop behaviour class LoopTests(GateCase): async def test_slow_srs_api_does_not_block_the_loop(self): self.stub.script = [("status", 200, 0.8)] ticks = 0 done = asyncio.Event() async def ticker(): nonlocal ticks while not done.is_set(): await asyncio.sleep(0.02) ticks += 1 async def run(): try: return await self.call(make_event()) finally: done.set() result, _ = await asyncio.gather(run(), ticker()) self.assertEqual(result, SKIP) self.assertGreaterEqual(ticks, 15, "event loop was blocked while waiting for srs-api") async def test_cancellation_is_re_raised(self): self.stub.script = [("status", 200, 0.6)] with mock.patch.object(gate, "TRIES", 1): task = asyncio.ensure_future(self.call(make_event())) await asyncio.sleep(0.15) task.cancel() with self.assertRaises(asyncio.CancelledError): await task # ---------------------------------------------------------------- registration class RegistrationTests(unittest.TestCase): def test_register_hooks_once(self): calls = [] ctx = SimpleNamespace(register_hook=lambda name, cb: calls.append((name, cb))) gate.register(ctx) self.assertEqual(calls, [("pre_gateway_dispatch", gate.on_inbound)]) def test_callback_is_async(self): self.assertTrue(inspect.iscoroutinefunction(gate.on_inbound)) def test_manifest(self): text = (HERE / "plugin.yaml").read_text(encoding="utf-8") self.assertIn("name: srs-gate", text) self.assertIn("- pre_gateway_dispatch", text) def test_returns_a_fresh_skip_each_time(self): a = asyncio.run(gate.on_inbound(event=None)) a["action"] = "allow" self.assertEqual(asyncio.run(gate.on_inbound(event=None)), SKIP) if __name__ == "__main__": unittest.main()