"""Tests for the iris plugin's HTTP fallback transport (docs/19). The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it from the source tree directly (same pattern as ``test_android.py``). Coverage (docs/19 §19.12): * auth: bad/missing token -> 401; missing device header -> 401; allowlist rejection -> 401 * ``POST /v1/frame``: valid ``message.send`` dispatches (202 + echo on the SSE stream); empty text -> 400 error frame; automation channel -> 400; bad JSON -> 400; wrong content-type -> 400; oversize body -> 413; media frames -> 400 (WS-only in v1); rate limit -> 429 * SSE: catch-up rows carry correct ``id``s + cursor envelope; ``event: hello`` present; a live frame appended after connect arrives on the stream; ``Last-Event-ID`` resume replays exactly the delta; heartbeat observed * long-poll: returns on new frame; empty 200 at timeout with advanced cursor * **delivery counting (docs/19 §19.8)**: a frame with only an SSE subscriber is ``delivered >= 1`` -> NO push fired (the critical regression test) Run via ``scripts/run_tests.sh tests/gateway/test_android_http.py``. """ from __future__ import annotations import asyncio import base64 import contextlib import importlib.util import json import os import sys import time from http.client import HTTPConnection from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock import pytest import pytest_asyncio # Test-only token (not a credential; the adapter is built with it via # monkeypatch in the fixture below). # pi-lens-ignore: S105 TOKEN = "test-iris-http-token-0123456789" DEVICE_ID = "test-http-device" CHAT_ID = "default" # 1x1 PNG (same fixture as test_android.py). PNG_1X1 = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" "AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" ) def _plugin_dir() -> Path: env = os.environ.get("IRIS_PLUGIN_DIR") if env: return Path(env) # Works from either copy of this file: tests/ (canonical, repo root is # parents[1]) or the hermes-agent/tests/gateway/ mirror (repo root is # parents[3]). The plugin always lives in /gateway-plugin. here = Path(__file__).resolve() for candidate in ( here.parents[1] / "gateway-plugin", here.parents[3] / "gateway-plugin", ): if (candidate / "protocol.py").is_file(): return candidate return here.parents[1] / "gateway-plugin" def _load_plugin(): """Load the gateway-plugin package under a unique module name (same pattern as test_android.py).""" name = "iris_plugin_http_under_test" cached = sys.modules.get(name) if cached is not None: return cached pkg_dir = _plugin_dir() if not (pkg_dir / "__init__.py").is_file(): pytest.fail(f"iris plugin not found at {pkg_dir}") spec = importlib.util.spec_from_file_location( name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)] ) if spec is None or spec.loader is None: pytest.fail(f"could not build import spec for {pkg_dir}") module = importlib.util.module_from_spec(spec) sys.modules[name] = module try: spec.loader.exec_module(module) except Exception: sys.modules.pop(name, None) raise return module @pytest.fixture(scope="module") def plugin(): return _load_plugin() @pytest.fixture def adapter(plugin, monkeypatch): """A live IrisAdapter with an isolated HERMES_HOME (conftest).""" monkeypatch.setenv("IRIS_TOKEN", TOKEN) from gateway.platform_registry import PlatformEntry, platform_registry if not platform_registry.is_registered("iris"): platform_registry.register( PlatformEntry( name="iris", label="Android", adapter_factory=lambda cfg: None, check_fn=lambda: True, ) ) config = SimpleNamespace( extra={ "host": "127.0.0.1", "port": 0, # ephemeral WS port "http_port": 0, # ephemeral HTTP port "max_upload_bytes": 1024 * 1024, }, home_channel=None, ) a = plugin.adapter.IrisAdapter(config) yield a with contextlib.suppress(Exception): a._devices.close() with contextlib.suppress(Exception): a._outbox.close() @pytest_asyncio.fixture async def gw(adapter): """Connected adapter (WS + HTTP legs up); the HTTP port is ephemeral.""" await adapter.connect() try: yield adapter finally: await adapter.disconnect() def http_port(adapter) -> int: assert adapter._http_server.enabled, "HTTP leg should be enabled after connect()" return adapter._http_server.bound_port # ── Blocking HTTP helpers (run via asyncio.to_thread) ────────────────────── def _request( port: int, method: str, path: str, *, token: str | None = TOKEN, device: str | None = DEVICE_ID, body: bytes | str | None = None, content_type: str = "application/json", timeout: float = 10.0, extra_headers: dict | None = None, ) -> tuple[int, bytes]: conn = HTTPConnection("127.0.0.1", port, timeout=timeout) headers = {} if token is not None: headers["Authorization"] = f"Bearer {token}" if device is not None: headers["X-Iris-Device"] = device if extra_headers: headers.update(extra_headers) if body is not None: data = body if isinstance(body, bytes) else body.encode("utf-8") headers["Content-Type"] = content_type conn.request(method, path, body=data, headers=headers) else: conn.request(method, path, headers=headers) resp = conn.getresponse() payload = resp.read() status = resp.status conn.close() return status, payload def _post_frame(port: int, frame: dict, **kw) -> tuple[int, dict]: status, payload = _request(port, "POST", "/v1/frame", body=json.dumps(frame), **kw) return status, json.loads(payload) def _frame_json(frame: dict) -> dict: return {"v": 1, **frame} def _parse_sse( lines: list[str], ) -> tuple[list[tuple[str | None, str | None, str]], int]: """Parse raw SSE lines into ``[(event, id, data), ...]`` + comment count.""" events: list[tuple[str | None, str | None, str]] = [] comments = 0 cur_event: str | None = None cur_id: str | None = None cur_data: list[str] = [] for raw in lines: line = raw.rstrip("\r\n") if line == "": if cur_data: events.append((cur_event, cur_id, "\n".join(cur_data))) cur_event, cur_id, cur_data = None, None, [] elif line.startswith(":"): comments += 1 else: field, _, value = line.partition(":") if value.startswith(" "): value = value[1:] if field == "event": cur_event = value elif field == "id": cur_id = value elif field == "data": cur_data.append(value) return events, comments def _sse_open( port: int, *, cursor: int | None = None, last_event_id: str | None = None ): """Open an SSE connection (blocking); returns the HTTPResponse (read lines via ``_sse_read_lines``; close with ``resp.close()``).""" conn = HTTPConnection("127.0.0.1", port, timeout=30) path = "/v1/events" + (f"?cursor={cursor}" if cursor is not None else "") headers = { "Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID, } if last_event_id is not None: headers["Last-Event-ID"] = last_event_id conn.request("GET", path, headers=headers) resp = conn.getresponse() assert resp.status == 200, f"SSE open failed: {resp.status}" assert resp.getheader("Content-Type", "").startswith("text/event-stream") return resp def _sse_read_lines(resp, n: int, timeout: float = 10.0) -> list[str]: """Read up to n lines from the SSE stream (blocking).""" raw = resp.fp.raw sock = getattr(raw, "_sock", None) if sock is not None: sock.settimeout(timeout) lines: list[str] = [] while len(lines) < n: line = resp.fp.readline() if not line: break lines.append(line.decode("utf-8")) return lines # ── /v1/health ────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_health_no_auth(gw): status, payload = await asyncio.to_thread( _request, http_port(gw), "GET", "/v1/health", token=None, device=None ) assert status == 200 assert json.loads(payload) == {"ok": True} @pytest.mark.asyncio async def test_unknown_path_404(gw): status, _ = await asyncio.to_thread(_request, http_port(gw), "GET", "/v1/nope") assert status == 404 # ── Auth ──────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_post_bad_token_401(gw): status, _ = await asyncio.to_thread( _request, http_port(gw), "POST", "/v1/frame", token="wrong-token", body=json.dumps(_frame_json({"type": "ping", "payload": {}})), ) assert status == 401 @pytest.mark.asyncio async def test_post_missing_token_401(gw): status, _ = await asyncio.to_thread( _request, http_port(gw), "POST", "/v1/frame", token=None, body=json.dumps(_frame_json({"type": "ping", "payload": {}})), ) assert status == 401 @pytest.mark.asyncio async def test_post_missing_device_401(gw): status, _ = await asyncio.to_thread( _request, http_port(gw), "POST", "/v1/frame", device=None, body=json.dumps(_frame_json({"type": "ping", "payload": {}})), ) assert status == 401 @pytest.mark.asyncio async def test_post_allowlist_rejection_401(gw): gw.allowed_users = ["some-other-device"] gw.allow_all = False status, _ = await asyncio.to_thread( _request, http_port(gw), "POST", "/v1/frame", body=json.dumps(_frame_json({"type": "ping", "payload": {}})), ) assert status == 401 # ── POST /v1/frame: validation ───────────────────────────────────────────── @pytest.mark.asyncio async def test_post_bad_json_400(gw): status, payload = await asyncio.to_thread( _request, http_port(gw), "POST", "/v1/frame", body=b"not json" ) assert status == 400 frame = json.loads(payload) assert frame["type"] == "error" @pytest.mark.asyncio async def test_post_wrong_content_type_400(gw): status, _ = await asyncio.to_thread( _request, http_port(gw), "POST", "/v1/frame", body=json.dumps(_frame_json({"type": "ping", "payload": {}})), content_type="text/plain", ) assert status == 400 @pytest.mark.asyncio async def test_post_oversize_body_413(gw): big = json.dumps( _frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}}) ) status, _ = await asyncio.to_thread( _request, http_port(gw), "POST", "/v1/frame", body=big ) assert status == 413 @pytest.mark.asyncio async def test_post_empty_message_400(gw): gw.handle_message = AsyncMock() status, payload = await asyncio.to_thread( _post_frame, http_port(gw), _frame_json( { "id": 7, "type": "message.send", "chat_id": CHAT_ID, "payload": {"text": " "}, } ), ) assert status == 400 frame = payload assert frame["type"] == "error" assert frame["id"] == 7 assert frame["payload"]["code"] == "unsupported" gw.handle_message.assert_not_called() @pytest.mark.asyncio async def test_post_automation_channel_400(gw): gw.handle_message = AsyncMock() entry = gw._channels.create(name="Cron") gw._channels.set_automation(entry["chat_id"], True) status, payload = await asyncio.to_thread( _post_frame, http_port(gw), _frame_json( { "id": 8, "type": "message.send", "chat_id": entry["chat_id"], "payload": {"text": "hi"}, } ), ) assert status == 400 assert payload["type"] == "error" gw.handle_message.assert_not_called() @pytest.mark.asyncio async def test_post_rate_limit_429(gw): gw.handle_message = AsyncMock() port = http_port(gw) # Exhaust the per-device bucket (INBOUND_BURST = 40) then expect 429. got_429 = False for i in range(60): status, _ = await asyncio.to_thread( _post_frame, port, _frame_json({"id": i, "type": "ping", "payload": {}}), ) if status == 429: got_429 = True break assert got_429, "expected a 429 within 60 rapid frames" # ── POST /v1/frame: dispatch ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_post_message_send_dispatches(gw): """202 ack; the user echo + read receipt arrive on the SSE stream; the agent turn fires (docs/19 §19.7: async responses on the event stream).""" gw.handle_message = AsyncMock() port = http_port(gw) conn = _sse_open(port) try: # Consume the open sequence (hello + status = 6 lines) first. lines = _sse_read_lines(conn, 6, timeout=5) events, _ = _parse_sse(lines) assert events[0][0] == "hello" status, payload = await asyncio.to_thread( _post_frame, port, _frame_json( { "id": 42, "type": "message.send", "chat_id": CHAT_ID, "payload": {"text": "hi there"}, } ), ) # The read receipt (sent when the turn is handed to the agent) is # the handler's single point-to-point reply -> 200 with the frame # as the body (a plain 202 {"ok": true} is also valid when no # synchronous reply exists). assert status in (200, 202) if status == 200: assert payload["type"] == "read.receipt" else: assert payload == {"ok": True} # The echo must arrive on the stream, tagged with its outbox # cursor as the SSE id (live frames carry no cursor in the # envelope, same as the WS path). deadline = time.monotonic() + 10 echo = None while time.monotonic() < deadline and echo is None: lines = _sse_read_lines(conn, 4, timeout=5) for _event, sse_id, data in _parse_sse(lines)[0]: frame = json.loads(data) if ( frame.get("type") == "message" and frame.get("payload", {}).get("text") == "hi there" ): echo = (frame, sse_id) assert echo is not None, "user echo did not arrive on the SSE stream" assert echo[1] is not None # SSE id = outbox cursor await asyncio.sleep(0.2) gw.handle_message.assert_called_once() finally: conn.close() # ── SSE: catch-up, hello, live, resume, heartbeat ────────────────────────── @pytest.mark.asyncio async def test_sse_catchup_and_hello(gw): """Catch-up rows carry correct ids + cursor envelope; hello present.""" port = http_port(gw) # Park two frames through the real outbound path (no live devices). push_calls: list = [] gw._maybe_push = AsyncMock(side_effect=lambda *a, **k: push_calls.append(a)) for text in ("one", "two"): await _park_frame(gw, text) cursors = [1, 2] conn = _sse_open(port, cursor=0) try: # 2 replayed frames (id + event + data + blank = 4 lines each) + # hello (3 lines) + status (3 lines) = 14 lines. lines = _sse_read_lines(conn, 14, timeout=5) events, _ = _parse_sse(lines) assert events[0][0] == "frame" assert events[0][1] == str(cursors[0]) f0 = json.loads(events[0][2]) assert f0["payload"]["text"] == "one" assert f0["cursor"] == cursors[0] assert events[1][0] == "frame" assert events[1][1] == str(cursors[1]) assert json.loads(events[1][2])["payload"]["text"] == "two" assert events[2][0] == "hello" hello = json.loads(events[2][2]) assert hello["type"] == "hello.ack" assert hello["payload"]["sync_cursor"] == 2 assert events[3][0] == "frame" assert json.loads(events[3][2])["type"] == "status" finally: conn.close() @pytest.mark.asyncio async def test_sse_live_frame_after_connect(gw): port = http_port(gw) conn = _sse_open(port) try: # Consume the open sequence (hello + status = 6 lines). _sse_read_lines(conn, 6, timeout=5) await _park_frame(gw, "live!") deadline = time.monotonic() + 10 got = None while time.monotonic() < deadline and got is None: lines = _sse_read_lines(conn, 4, timeout=5) for _event, sse_id, data in _parse_sse(lines)[0]: frame = json.loads(data) if frame.get("payload", {}).get("text") == "live!": got = (frame, sse_id) assert got is not None, "live frame did not arrive on the SSE stream" assert got[1] is not None # SSE id = outbox cursor finally: conn.close() @pytest.mark.asyncio async def test_sse_last_event_id_resume(gw): """Resume with Last-Event-ID replays exactly the delta.""" port = http_port(gw) for text in ("a", "b", "c"): await _park_frame(gw, text) conn = _sse_open(port, last_event_id="1") try: # Frames 2 and 3 replayed (8 lines) + hello (3) + status (3) = 14. lines = _sse_read_lines(conn, 14, timeout=5) events, _ = _parse_sse(lines) replayed = [e for e in events if e[0] == "frame" and e[1] is not None] assert [e[1] for e in replayed] == ["2", "3"] assert json.loads(replayed[0][2])["payload"]["text"] == "b" assert json.loads(replayed[1][2])["payload"]["text"] == "c" finally: conn.close() @pytest.mark.asyncio async def test_sse_heartbeat(gw, monkeypatch, plugin): """A comment heartbeat is written when the stream is idle.""" monkeypatch.setattr(plugin.http_server, "SSE_HEARTBEAT_S", 1.0) port = http_port(gw) conn = _sse_open(port) try: # Consume the open sequence (6 lines), then wait for the heartbeat. _sse_read_lines(conn, 6, timeout=5) lines = _sse_read_lines(conn, 2, timeout=5) events, comments = _parse_sse(lines) assert comments >= 1, f"no heartbeat comment in {lines!r}" assert events == [] finally: conn.close() # ── Long-poll ─────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_poll_returns_on_new_frame(gw): port = http_port(gw) await _park_frame(gw, "pre") # cursor 1: returned immediately (catch-up) # A second poll at the high-water mark blocks until a new frame lands. def poll_and_broadcast(): status, payload = _request(port, "GET", "/v1/poll?cursor=1", timeout=30) return status, json.loads(payload) async def late_frame(): await asyncio.sleep(0.5) await _park_frame(gw, "late") poll_task = asyncio.create_task(asyncio.to_thread(poll_and_broadcast)) late_task = asyncio.create_task(late_frame()) status, body = await asyncio.wait_for(poll_task, timeout=15) await late_task assert status == 200 assert body["cursor"] >= 2 assert len(body["frames"]) == 1 assert json.loads(body["frames"][0])["payload"]["text"] == "late" @pytest.mark.asyncio async def test_poll_timeout_empty(gw, monkeypatch, plugin): monkeypatch.setattr(plugin.http_server, "POLL_TIMEOUT_S", 1.0) port = http_port(gw) await _park_frame(gw, "x") hwm = gw._outbox.latest_cursor() status, payload = await asyncio.to_thread( _request, port, "GET", f"/v1/poll?cursor={hwm}", timeout=15 ) body = json.loads(payload) assert status == 200 assert body["frames"] == [] assert body["cursor"] == hwm # ── Delivery counting (docs/19 §19.8 — the critical regression) ──────────── @pytest.mark.asyncio async def test_sse_subscriber_counts_as_delivered_no_push(gw): """A frame with only an SSE subscriber is delivered >= 1 -> NO push.""" port = http_port(gw) conn = _sse_open(port) try: _sse_read_lines(conn, 6, timeout=5) # open sequence push = AsyncMock() gw._maybe_push = push await _park_frame(gw, "no push for me") await asyncio.sleep(0.2) push.assert_not_called() finally: conn.close() @pytest.mark.asyncio async def test_no_subscribers_still_pushes(gw): """Control: with no live devices at all, the push path still fires.""" push = AsyncMock() gw._maybe_push = push await _park_frame(gw, "wake me up") await asyncio.sleep(0.2) push.assert_called_once() # ── Media over HTTP (docs/19 §19.15, v2) ────────────────────────────────── def _upload( port: int, data: bytes, *, media_ref: str = "mu_http1", kind: str = "image", mime: str = "image/png", filename: str = "t.png", sha256: str | None = None, **kw, ) -> tuple[int, dict]: import hashlib headers = { "X-Iris-Media-Ref": media_ref, "X-Iris-Media-Kind": kind, "X-Iris-Media-Filename": filename, "X-Iris-Media-Sha256": sha256 if sha256 is not None else hashlib.sha256(data).hexdigest(), } status, payload = _request( port, "POST", "/v1/media", body=data, content_type=mime, extra_headers=headers, **kw, ) return status, json.loads(payload) @pytest.mark.asyncio async def test_media_upload_ok(gw): port = http_port(gw) status, body = await asyncio.to_thread(_upload, port, PNG_1X1) assert status == 201, body assert body["type"] == "media.upload.ack" assert body["payload"]["ok"] is True assert body["payload"]["media_ref"] == "mu_http1" entry = gw._media.get_inbound("mu_http1") assert entry is not None assert entry.kind == "image" assert entry.size == len(PNG_1X1) @pytest.mark.asyncio async def test_media_upload_sha_mismatch(gw): port = http_port(gw) status, body = await asyncio.to_thread( _upload, port, PNG_1X1, media_ref="mu_badsha", sha256="0" * 64 ) assert status == 500, body # internal: digest mismatch assert body["type"] == "error" assert body["payload"]["code"] == "internal" assert gw._media.get_inbound("mu_badsha") is None @pytest.mark.asyncio async def test_media_upload_oversize_413(gw): port = http_port(gw) oversize = b"x" * (gw.max_upload_bytes + 1) status, body = await asyncio.to_thread(_upload, port, oversize, media_ref="mu_big") assert status == 413, body assert body["payload"]["code"] == "media_too_large" @pytest.mark.asyncio async def test_media_upload_missing_ref_400(gw): port = http_port(gw) status, payload = await asyncio.to_thread( _request, port, "POST", "/v1/media", body=PNG_1X1, content_type="image/png", extra_headers={"X-Iris-Media-Kind": "image"}, ) body = json.loads(payload) assert status == 400 assert body["payload"]["code"] == "unsupported" @pytest.mark.asyncio async def test_media_upload_bad_kind_400(gw): port = http_port(gw) status, body = await asyncio.to_thread(_upload, port, PNG_1X1, kind="hologram") assert status == 400 assert body["payload"]["code"] == "unsupported" @pytest.mark.asyncio async def test_media_upload_auth_401(gw): port = http_port(gw) status, _ = await asyncio.to_thread(_upload, port, PNG_1X1, token="wrong-token") assert status == 401 @pytest.mark.asyncio async def test_media_upload_liar_reclassified(gw): """Lies about being a PNG: magic-byte re-sniff keeps it out of the image cache (lands as a document) — same contract as the WS path.""" port = http_port(gw) payload = b"not an image" status, body = await asyncio.to_thread( _upload, port, payload, media_ref="mu_liar", filename="liar.html" ) assert status == 201, body entry = gw._media.get_inbound("mu_liar") assert entry is not None assert entry.kind == "document" @pytest.mark.asyncio async def test_media_pull_ok(gw): from gateway.platforms.base import get_image_cache_dir img = get_image_cache_dir() / "http_pull_test.png" img.write_bytes(PNG_1X1) entry = gw._media.register_outbound( str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1) ) port = http_port(gw) status, payload = await asyncio.to_thread( _request, port, "GET", f"/v1/media/{entry.media_id}" ) assert status == 200 assert payload == PNG_1X1 conn = HTTPConnection("127.0.0.1", port, timeout=10) conn.request( "GET", f"/v1/media/{entry.media_id}", headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID}, ) resp = conn.getresponse() resp.read() assert resp.getheader("Content-Type") == "image/png" assert resp.getheader("Content-Length") == str(len(PNG_1X1)) conn.close() @pytest.mark.asyncio async def test_media_pull_unknown_404(gw): port = http_port(gw) status, payload = await asyncio.to_thread( _request, port, "GET", "/v1/media/md_nope" ) body = json.loads(payload) assert status == 404 assert body["payload"]["code"] == "not_found" @pytest.mark.asyncio async def test_media_pull_denied_path_404(gw): """Known id, but the path fails delivery validation (denylist) — same re-check at pull time as the WS path.""" entry = gw._media.register_outbound( "/etc/passwd", "document", "text/plain", "passwd", 100 ) port = http_port(gw) status, payload = await asyncio.to_thread( _request, port, "GET", f"/v1/media/{entry.media_id}" ) body = json.loads(payload) assert status == 404 assert body["payload"]["code"] == "not_found" # ── Helpers ───────────────────────────────────────────────────────────────── async def _park_frame(adapter, text: str) -> int: """Emit one message frame through ``_broadcast_or_log`` (the real outbound path); returns the outbox cursor.""" plugin = _load_plugin() frame = plugin.protocol.message( chat_id=CHAT_ID, message_id=f"m_{abs(hash(text)) % 10**8:08x}", role="assistant", text=text, ) await adapter._broadcast_or_log(CHAT_ID, frame) return adapter._outbox.latest_cursor()