HTTP transport: drop WS server, offline send queue + dead-stream watchdog
CI / Gateway plugin tests (push) Successful in 5m9s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m55s

Gateway (docs/19):
- Remove ws_server.py; frame dispatch factored into dispatch.py
- http_server: media upload/pull, pairing over HTTP
- protocol: media frames mirrored; tests + ws_probe updated for HTTP

App:
- HttpGateway: postFrame/uploadMedia/pullMedia no longer throw on
  network failure (PostResult ok=false / Result.failure) — uncaught
  SocketTimeoutException on Dispatchers.Default crashed the app
- GatewayClient: dead-stream watchdog (health probe every 10s, 2
  failures -> redial in ~20s instead of the 45s SSE read timeout);
  state flips to Reconnecting when the stream dies, restored from the
  last hello.ack on long-poll success; poke() + backoff reset on app
  resume (MainActivity.onResume)
- Offline sends: composer enabled while disconnected; a send with no
  response (status 0) stays queued (Pending) and is auto-resent on the
  next (re)connect after a 2s outbox-replay grace; gateway 4xx
  rejections fail the bubble (tap to retry, no auto-loop)
- ChatStore: echo-replace and thread-relocate also match Failed
  bubbles (POST response lost in a network drop); loadHistory dedupes
  local failed bubbles the server already has; failMessage()
- MainActivity: poke() on resume so a backgrounded app reconnects
  promptly instead of waiting out the backoff
This commit is contained in:
ARIA committed 2026-08-22 20:10:05 +02:00
1 parent 2349a95dd4
commit e6015033b6
22 files changed
+2804 -2439

No files matched your search

+337 -276
View File
@@ -5,13 +5,13 @@ The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into
from the source tree directly so they never depend on that install.
Coverage (docs/13-testing.md §13.1, media bullets):
* upload start -> binary chunks -> end reassembles + sha256 verified
* over-limit (declared and mid-stream) -> ``media_too_large``
* upload via ``POST /v1/media`` reassembles + sha256 verified
* over-limit (Content-Length) -> ``media_too_large``
* sha256 mismatch -> ``internal``
* ``message.send`` with ``media_refs`` -> echo carries ``media[]`` and the
``MessageEvent`` carries ``media_urls``/``media_types``
* ``send_*`` -> ``media.offer`` (fields + message association)
* ``media.pull`` serves only allowed paths (denied/unknown -> ``not_found``)
* ``GET /v1/media/{id}`` serves only allowed paths (denied/unknown -> ``not_found``)
* kind re-sniffing (don't trust the client)
Run via ``scripts/run_tests.sh tests/gateway/test_android.py``.
@@ -26,6 +26,9 @@ import importlib.util
import json
import os
import sys
import socket
import threading
from http.client import HTTPConnection
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -47,8 +50,14 @@ def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR")
if env:
return Path(env)
# hermes-agent/tests/gateway/test_android.py -> repo root is parents[3].
return Path(__file__).resolve().parents[3] / "gateway-plugin"
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
# plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror
# (repo root is parents[3]).
here = Path(__file__).resolve()
for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"):
if (candidate / "protocol.py").is_file():
return candidate
return here.parents[1]
def _load_plugin():
@@ -105,7 +114,7 @@ def adapter(plugin, monkeypatch):
config = SimpleNamespace(
extra={
"host": "127.0.0.1",
"port": 0, # ephemeral port
"http_port": 0, # ephemeral HTTP port
"max_upload_bytes": 1024 * 1024, # 1 MiB -- keeps over-limit tests fast
},
home_channel=None,
@@ -122,48 +131,206 @@ def adapter(plugin, monkeypatch):
pass
async def _hello(ws) -> dict:
await ws.send(
json.dumps(
{
"v": 1,
"type": "hello",
"payload": {
"token": TOKEN,
"device_id": DEVICE_ID,
"device_name": "Test Device",
"caps": {},
},
}
class HttpTestClient:
"""Mimics the old WS client interface over the HTTP transport (docs/19).
``.send(json_str)`` -> ``POST /v1/frame``; ``.recv(timeout)`` -> the next
frame from the SSE stream (a dict); ``.upload(...)`` -> ``POST /v1/media``
returning the terminal frame (ack or error). The SSE stream is read in a
background thread that feeds an asyncio queue, so ``recv`` is awaitable
exactly like the old WS ``recv``.
"""
def __init__(self, port: int, cursor: int = 0):
self._port = port
self._cursor = cursor
self._queue: asyncio.Queue = asyncio.Queue()
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._resp = None
async def start(self) -> dict:
"""Open the SSE stream; returns the hello.ack frame. Any outbox
catch-up frames replayed before the hello are buffered and re-enqueued
so ``recv`` can still see them."""
self._loop = asyncio.get_running_loop()
self._thread = threading.Thread(target=self._sse_reader, daemon=True)
self._thread.start()
buffered: list = []
hello = None
while hello is None:
frame = await asyncio.wait_for(self._queue.get(), timeout=5)
if frame is None:
raise AssertionError("SSE stream closed before hello")
if frame.get("type") == "hello.ack":
hello = frame
else:
buffered.append(frame)
for f in buffered:
self._queue.put_nowait(f)
return hello
def _sse_reader(self) -> None:
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
conn.request(
"GET",
f"/v1/events?cursor={self._cursor}",
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
)
)
raw = await asyncio.wait_for(ws.recv(), timeout=5)
ack = json.loads(raw)
assert ack["type"] == "hello.ack", f"expected hello.ack, got {ack}"
return ack
resp = conn.getresponse()
self._resp = resp
cur_data: list[str] = []
loop = self._loop
assert loop is not None
while True:
line = resp.fp.readline()
if not line:
break
line = line.decode("utf-8").rstrip("\r\n")
if line == "":
if cur_data:
try:
frame = json.loads("\n".join(cur_data))
except Exception:
frame = None
if frame is not None:
loop.call_soon_threadsafe(self._queue.put_nowait, frame)
cur_data = []
elif line.startswith(":"):
continue # heartbeat comment
elif line.startswith("data:"):
cur_data.append(line[5:].lstrip())
# id:/event: fields are not needed for the test shim
loop.call_soon_threadsafe(self._queue.put_nowait, None) # EOF sentinel
async def send(self, json_str: str) -> None:
await asyncio.to_thread(self._post_frame, json_str)
def _post_frame(self, json_str: str) -> None:
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
conn.request(
"POST",
"/v1/frame",
body=json_str.encode("utf-8"),
headers={
"Authorization": f"Bearer {TOKEN}",
"X-Iris-Device": DEVICE_ID,
"Content-Type": "application/json",
},
)
resp = conn.getresponse()
body = resp.read()
conn.close()
# Fast responses (validation, channel ops, …) come back on the POST
# response body via the reply sink, NOT the SSE stream. Enqueue any
# protocol frame so recv_until sees it (the plain {"ok":true} ack is
# not a frame and is skipped).
if body:
try:
obj = json.loads(body)
except Exception:
obj = None
if isinstance(obj, dict) and "type" in obj:
assert self._loop is not None
self._loop.call_soon_threadsafe(self._queue.put_nowait, obj)
async def recv(self, timeout: float = 10.0) -> dict:
frame = await asyncio.wait_for(self._queue.get(), timeout=timeout)
if frame is None:
raise ConnectionError("SSE stream closed")
return frame
async def upload(
self,
media_ref: str,
data: bytes,
*,
kind: str = "image",
mime: str = "image/png",
filename: str = "t.png",
sha256: str | None = None,
) -> dict:
"""Drive a media upload via ``POST /v1/media``; returns the terminal
frame (``media.upload.ack`` or ``error``)."""
def _do() -> dict:
conn = HTTPConnection("127.0.0.1", self._port, timeout=60)
conn.request(
"POST",
"/v1/media",
body=data,
headers={
"Authorization": f"Bearer {TOKEN}",
"X-Iris-Device": DEVICE_ID,
"Content-Type": mime,
"X-Iris-Media-Ref": media_ref,
"X-Iris-Media-Kind": kind,
"X-Iris-Media-Filename": filename,
"X-Iris-Media-Sha256": sha256
or hashlib.sha256(data).hexdigest(),
},
)
resp = conn.getresponse()
body = resp.read()
conn.close()
return json.loads(body)
return await asyncio.to_thread(_do)
async def pull(self, media_id: str) -> tuple[int, bytes]:
"""Drive a media pull via ``GET /v1/media/{id}``; returns (status, body)."""
def _do() -> tuple[int, bytes]:
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
conn.request(
"GET",
f"/v1/media/{media_id}",
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
)
resp = conn.getresponse()
body = resp.read()
status = resp.status
conn.close()
return status, body
return await asyncio.to_thread(_do)
async def close(self) -> None:
# Interrupt the reader thread's blocking readline() by shutting down
# the socket first; otherwise resp.close() blocks until the in-flight
# read returns (the file lock is held for the whole blocking read).
if self._resp is not None:
sock = getattr(self._resp.fp, "raw", None)
sock = getattr(sock, "_sock", None) if sock is not None else None
if sock is not None:
try:
sock.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
self._resp.close()
except Exception:
pass
@pytest_asyncio.fixture
async def ws_client(adapter):
"""Connected + paired WS client; the adapter's server runs on an
ephemeral port for the duration of the test."""
from websockets.asyncio.client import connect
"""Connected + paired HTTP client; the adapter's server runs on an
ephemeral port for the duration of the test. Yields ``(client, ack)`` to
match the old WS fixture shape so test bodies need no signature change."""
await adapter.connect()
client = HttpTestClient(adapter._http_server.bound_port)
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(
f"ws://127.0.0.1:{port}/ws", max_size=4 * 1024 * 1024
) as ws:
ack = await _hello(ws)
yield ws, ack
ack = await client.start()
yield client, ack
finally:
await client.close()
await adapter.disconnect()
async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
"""Collect frames (dicts; binary frames as ("binary", bytes)) until
*predicate* matches a JSON frame. Returns all frames collected."""
"""Collect frames (dicts) until *predicate* matches a frame. Returns all
frames collected."""
frames: list = []
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
@@ -172,13 +339,9 @@ async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
if remaining <= 0:
raise AssertionError(
"timed out waiting for frame; got: "
+ ", ".join(f.get("type", "?") if isinstance(f, dict) else "binary" for f in frames)
+ ", ".join(f.get("type", "?") if isinstance(f, dict) else "?" for f in frames)
)
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
if isinstance(raw, (bytes, bytearray)):
frames.append(("binary", bytes(raw)))
continue
frame = json.loads(raw)
frame = await ws.recv(timeout=remaining)
frames.append(frame)
if predicate(frame):
return frames
@@ -186,43 +349,42 @@ async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
async def upload_file(ws, media_ref: str, data: bytes, *, kind: str = "image",
mime: str = "image/png", filename: str = "t.png",
request_id: int = 1) -> dict:
"""Drive a full media.upload flow; returns the terminal frame (ack or error)."""
await ws.send(
json.dumps(
{
"v": 1,
"id": request_id,
"type": "media.upload.start",
"payload": {
"media_ref": media_ref,
"kind": kind,
"mime": mime,
"size": len(data),
"filename": filename,
},
}
)
request_id: int = 1, sha256: str | None = None) -> dict:
"""Drive a media upload via the HTTP leg; returns the terminal frame
(ack or error)."""
return await ws.upload(
media_ref, data, kind=kind, mime=mime, filename=filename, sha256=sha256
)
# Two chunks to exercise reassembly.
half = len(data) // 2
await ws.send(data[:half])
await ws.send(data[half:])
await ws.send(
json.dumps(
{
"v": 1,
"id": request_id + 1,
"type": "media.upload.end",
"payload": {
"media_ref": media_ref,
"sha256": hashlib.sha256(data).hexdigest(),
},
}
# ── Lifecycle ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_disconnect_broadcasts_status_restarting(adapter):
"""Teardown broadcasts ``status{state=restarting}`` before closing the
streams, so the app can distinguish a clean gateway teardown (restart/
stop) from a plain network drop — it shows the "Gateway restarting" chat
notice only when this frame was received (docs/04 §status)."""
await adapter.connect()
client = HttpTestClient(adapter._http_server.bound_port)
try:
await client.start()
await adapter.disconnect()
frames = []
while True:
try:
frames.append(await client.recv(timeout=5))
except (ConnectionError, asyncio.TimeoutError):
break
statuses = [f for f in frames if f.get("type") == "status"]
assert any(f["payload"]["state"] == "restarting" for f in statuses), (
f"expected status{{restarting}} before close, got: {frames}"
)
)
frames = await recv_until(ws, lambda f: f.get("type") in ("media.upload.ack", "error"))
return frames[-1]
finally:
await client.close()
# Idempotent: the second call is a no-op on the already-stopped server.
await adapter.disconnect()
# ── Pure helpers ────────────────────────────────────────────────────────────
@@ -375,7 +537,6 @@ async def test_upload_reassembles_verifies_and_caches(adapter, ws_client):
terminal = await upload_file(ws, "mu_t1", PNG_1X1)
assert terminal["type"] == "media.upload.ack", terminal
assert terminal["id"] == 2
assert terminal["payload"]["ok"] is True
assert terminal["payload"]["media_ref"] == "mu_t1"
@@ -396,100 +557,37 @@ async def test_upload_reassembles_verifies_and_caches(adapter, ws_client):
async def test_upload_declared_over_limit_rejected(adapter, ws_client):
ws, _ = ws_client
limit = adapter.max_upload_bytes
await ws.send(
json.dumps(
{
"v": 1,
"id": 1,
"type": "media.upload.start",
"payload": {
"media_ref": "mu_big",
"kind": "document",
"mime": "application/pdf",
"size": limit + 1,
"filename": "big.pdf",
},
}
)
# Over HTTP the server checks Content-Length before reading the body.
err = await upload_file(
ws, "mu_big", b"x" * (limit + 1), kind="document", mime="application/pdf",
filename="big.pdf",
)
frames = await recv_until(ws, lambda f: f.get("type") == "error")
err = frames[-1]
assert err["type"] == "error"
assert err["payload"]["code"] == "media_too_large"
assert err["id"] == 1
@pytest.mark.asyncio
async def test_upload_midstream_over_limit_rejected(adapter, ws_client):
async def test_upload_over_limit_ref_not_consumed(adapter, ws_client):
"""Over HTTP the over-limit check happens before the body is read, so a
rejected upload must not consume its media_ref (a later valid upload with
the same ref succeeds)."""
ws, _ = ws_client
limit = adapter.max_upload_bytes
await ws.send(
json.dumps(
{
"v": 1,
"id": 1,
"type": "media.upload.start",
"payload": {
"media_ref": "mu_mid",
"kind": "document",
"mime": "application/octet-stream",
"size": limit,
"filename": "mid.bin",
},
}
)
)
# Declared size passes the start check; the stream exceeds it.
await ws.send(b"x" * (limit // 2))
await ws.send(b"x" * (limit // 2 + 1))
frames = await recv_until(ws, lambda f: f.get("type") == "error")
assert frames[-1]["payload"]["code"] == "media_too_large"
# The session is discarded: a late end cannot complete it.
await ws.send(
json.dumps(
{
"v": 1,
"id": 2,
"type": "media.upload.end",
"payload": {"media_ref": "mu_mid", "sha256": "0" * 64},
}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2)
assert frames[-1]["payload"]["code"] == "not_found"
err = await upload_file(ws, "mu_reuse", b"x" * (limit + 1), kind="document")
assert err["type"] == "error"
assert err["payload"]["code"] == "media_too_large"
# The ref is free: a valid upload with the same ref now succeeds.
ok = await upload_file(ws, "mu_reuse", PNG_1X1)
assert ok["type"] == "media.upload.ack", ok
assert ok["payload"]["ok"] is True
@pytest.mark.asyncio
async def test_upload_sha256_mismatch_rejected(adapter, ws_client):
ws, _ = ws_client
await ws.send(
json.dumps(
{
"v": 1,
"id": 1,
"type": "media.upload.start",
"payload": {
"media_ref": "mu_bad",
"kind": "image",
"mime": "image/png",
"size": len(PNG_1X1),
"filename": "bad.png",
},
}
)
)
await ws.send(PNG_1X1)
await ws.send(
json.dumps(
{
"v": 1,
"id": 2,
"type": "media.upload.end",
"payload": {"media_ref": "mu_bad", "sha256": "0" * 64},
}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error")
assert frames[-1]["payload"]["code"] == "internal"
err = await upload_file(ws, "mu_bad", PNG_1X1, sha256="0" * 64)
assert err["type"] == "error"
assert err["payload"]["code"] == "internal"
assert adapter._media.get_inbound("mu_bad") is None
@@ -1223,47 +1321,26 @@ async def test_pull_serves_allowed_path(adapter, ws_client):
str(img), "image", "image/png", "pull_test.png", len(PNG_1X1)
)
await ws.send(
json.dumps(
{"v": 1, "id": 9, "type": "media.pull", "payload": {"media_id": entry.media_id}}
)
)
chunks: list[bytes] = []
terminal = None
while terminal is None:
raw = await asyncio.wait_for(ws.recv(), timeout=10)
if isinstance(raw, (bytes, bytearray)):
chunks.append(bytes(raw))
continue
frame = json.loads(raw)
if frame.get("type") == "media.pull.end":
terminal = frame
assert terminal["id"] == 9
assert terminal["payload"]["ok"] is True
assert b"".join(chunks) == PNG_1X1
status, body = await ws.pull(entry.media_id)
assert status == 200
assert body == PNG_1X1
@pytest.mark.asyncio
async def test_pull_rejects_unknown_and_denied(adapter, ws_client):
ws, _ = ws_client
# Unknown media_id.
await ws.send(
json.dumps({"v": 1, "id": 1, "type": "media.pull", "payload": {"media_id": "md_nope"}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
assert frames[-1]["payload"]["code"] == "not_found"
status, body = await ws.pull("md_nope")
assert status == 404
assert json.loads(body)["payload"]["code"] == "not_found"
# Known id, but the path fails delivery validation (denylist).
entry = adapter._media.register_outbound(
"/etc/passwd", "document", "text/plain", "passwd", 100
)
await ws.send(
json.dumps(
{"v": 1, "id": 2, "type": "media.pull", "payload": {"media_id": entry.media_id}}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2)
assert frames[-1]["payload"]["code"] == "not_found"
status, body = await ws.pull(entry.media_id)
assert status == 404
assert json.loads(body)["payload"]["code"] == "not_found"
# Known id, file deleted since the offer.
from gateway.platforms.base import get_image_cache_dir
@@ -1274,13 +1351,9 @@ async def test_pull_rejects_unknown_and_denied(adapter, ws_client):
str(img), "image", "image/png", "gone.png", len(PNG_1X1)
)
img.unlink()
await ws.send(
json.dumps(
{"v": 1, "id": 3, "type": "media.pull", "payload": {"media_id": entry2.media_id}}
)
)
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 3)
assert frames[-1]["payload"]["code"] == "not_found"
status, body = await ws.pull(entry2.media_id)
assert status == 404
assert json.loads(body)["payload"]["code"] == "not_found"
# ── M5: push backends (pure) ───────────────────────────────────────────────
@@ -1653,7 +1726,7 @@ async def test_push_skipped_when_backend_unconfigured(adapter):
@pytest.mark.asyncio
async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client):
async def test_fcm_register_updates_registry(adapter, ws_client):
ws, _ = ws_client
await ws.send(
json.dumps(
@@ -1664,7 +1737,7 @@ async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client):
}
)
)
# Ordering barrier: WS frames are processed in order, so by the time the
# Ordering barrier: frames are processed in order, so by the time the
# channel.list response arrives, fcm.register has been handled.
await ws.send(
json.dumps({"v": 1, "id": 1, "type": "channel.list", "payload": {}})
@@ -1673,16 +1746,13 @@ async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client):
dev = adapter._devices.get(DEVICE_ID)
assert dev["fcm_token"] == "rotated-token"
assert dev["ntfy_topic"] == "dev-topic"
conn = adapter._ws_server.connection(DEVICE_ID)
assert conn.fcm_token == "rotated-token"
assert conn.ntfy_topic == "dev-topic"
# The next offline push targets the rotated token.
# The next offline push targets the rotated token. Close the stream and
# force-remove the (possibly stale) subscriber so the push isn't counted
# as delivered to a dead connection.
await ws.close()
for _ in range(100):
if not adapter._ws_server.has_devices():
break
await asyncio.sleep(0.01)
with adapter._http_server._subs_lock:
adapter._http_server._subs.clear()
fake = _FakePush()
adapter._push = fake
await adapter.send("android:default", "after rotation", metadata={"notify": True})
@@ -1898,32 +1968,32 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter):
await adapter.send("android:default", "two", metadata={"notify": True})
assert adapter._outbox.latest_cursor() == 2
from websockets.asyncio.client import connect
await adapter.connect()
# Open at the latest cursor so the SSE catch-up doesn't replay the parked
# frames (the sync request below is what we're testing).
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
ack = await _hello(ws)
assert ack["payload"]["sync_cursor"] == 2
await ws.send(
json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
texts = [f["payload"]["text"] for f in frames if f.get("type") == "message"]
assert texts == ["one", "two"]
done = frames[-1]
assert done["id"] == 10
assert done["payload"]["cursor"] == 2
# A sync at the current cursor replays nothing.
await ws.send(
json.dumps({"v": 1, "id": 11, "type": "sync", "payload": {"cursor": 2}})
)
frames = await recv_until(
ws, lambda f: f.get("type") == "sync.done" and f.get("id") == 11
)
assert len(frames) == 1
ack = await ws.start()
assert ack["payload"]["sync_cursor"] == 2
await ws.send(
json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
texts = [f["payload"]["text"] for f in frames if f.get("type") == "message"]
assert texts == ["one", "two"]
done = frames[-1]
assert done["id"] == 10
assert done["payload"]["cursor"] == 2
# A sync at the current cursor replays nothing.
await ws.send(
json.dumps({"v": 1, "id": 11, "type": "sync", "payload": {"cursor": 2}})
)
frames = await recv_until(
ws, lambda f: f.get("type") == "sync.done" and f.get("id") == 11
)
assert len(frames) == 1
finally:
await ws.close()
await adapter.disconnect()
@@ -1931,15 +2001,13 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter):
async def test_hello_ack_last_pushed_cursor_default_zero(adapter):
"""A device that never received a push reports last_pushed_cursor=0 in
hello.ack (docs/08 §8.7 dedupe watermark)."""
from websockets.asyncio.client import connect
await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port)
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
ack = await _hello(ws)
assert ack["payload"]["last_pushed_cursor"] == 0
ack = await ws.start()
assert ack["payload"]["last_pushed_cursor"] == 0
finally:
await ws.close()
await adapter.disconnect()
@@ -1949,8 +2017,6 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
next hello.ack reports it — the app uses it to skip re-notifying
sync-replayed frames (docs/08 §8.7). Back-to-back frames for the same
chat coalesce into one push; a failed push does not advance the cursor."""
from websockets.asyncio.client import connect
fake = _FakePush()
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
@@ -1978,12 +2044,12 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
ack = await _hello(ws)
assert ack["payload"]["last_pushed_cursor"] == 3
ack = await ws.start()
assert ack["payload"]["last_pushed_cursor"] == 3
finally:
await ws.close()
await adapter.disconnect()
@@ -1992,25 +2058,23 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
"""Frames replayed by sync carry their outbox cursor in the envelope so
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
frames carry no cursor."""
from websockets.asyncio.client import connect
await adapter.send("android:default", "one", metadata={"notify": True})
await adapter.send("android:default", "two", metadata={"notify": True})
await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
await _hello(ws)
await ws.send(
json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
msgs = [f for f in frames if f.get("type") == "message"]
assert [f["cursor"] for f in msgs] == [1, 2]
# sync.done itself carries no envelope cursor.
assert "cursor" not in frames[-1]
await ws.start()
await ws.send(
json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}})
)
frames = await recv_until(ws, lambda f: f.get("type") == "sync.done")
msgs = [f for f in frames if f.get("type") == "message"]
assert [f["cursor"] for f in msgs] == [1, 2]
# sync.done itself carries no envelope cursor.
assert "cursor" not in frames[-1]
finally:
await ws.close()
await adapter.disconnect()
@@ -2129,30 +2193,27 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
@pytest.mark.asyncio
async def test_wrong_token_rejected(adapter):
from websockets.asyncio.client import connect
"""A request with a wrong Bearer token is rejected with 401 (the HTTP
equivalent of the WS hello auth rejection)."""
await adapter.connect()
try:
port = adapter._ws_server._server.sockets[0].getsockname()[1]
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
await ws.send(
json.dumps(
{
"v": 1,
"type": "hello",
"payload": {
"token": "wrong-token",
"device_id": DEVICE_ID,
"device_name": "Bad",
"caps": {},
},
}
)
port = adapter._http_server.bound_port
def _req() -> int:
conn = HTTPConnection("127.0.0.1", port, timeout=5)
conn.request(
"GET",
"/v1/events",
headers={"Authorization": "Bearer wrong-token", "X-Iris-Device": DEVICE_ID},
)
raw = await asyncio.wait_for(ws.recv(), timeout=5)
err = json.loads(raw)
assert err["type"] == "error"
assert err["payload"]["code"] == "auth"
resp = conn.getresponse()
resp.read()
status = resp.status
conn.close()
return status
status = await asyncio.to_thread(_req)
assert status == 401
finally:
await adapter.disconnect()