1060 lines
37 KiB
Python
1060 lines
37 KiB
Python
"""Unit tests for the X Chat platform plugin.
|
|
|
|
All tests run offline: the X API layer is replaced with fakes and the Chat
|
|
XDK crypto core is replaced with a stub — no chatxdk native module, no
|
|
network, no gateway process.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import MessageEvent, MessageType
|
|
from plugins.platforms.xchat import adapter as xchat_adapter
|
|
from plugins.platforms.xchat.adapter import (
|
|
XChatAdapter,
|
|
_compile_mention_patterns,
|
|
_env_enablement,
|
|
check_requirements,
|
|
)
|
|
from plugins.platforms.xchat.crypto import XChatCrypto, message_text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers / fakes
|
|
|
|
|
|
class FakeCrypto:
|
|
"""Stands in for XChatCrypto — records calls, no native SDK."""
|
|
|
|
def __init__(self) -> None:
|
|
self.encrypted: List[tuple] = []
|
|
self.batch_calls: List[List[str]] = []
|
|
self.signing_keys: List[Dict[str, str]] = []
|
|
self.decrypt_map: Dict[str, Dict[str, Any]] = {}
|
|
self.fail_encrypt: Optional[Exception] = None
|
|
self.replies: List[tuple] = []
|
|
self.media_encrypted: List[bytes] = []
|
|
self.media_decrypted: List[bytes] = []
|
|
|
|
def decrypt_one(self, event_b64, conversation_keys=None):
|
|
return self.decrypt_map[event_b64]
|
|
|
|
def decrypt_batch(self, events_b64):
|
|
self.batch_calls.append(list(events_b64))
|
|
return {
|
|
"messages": [],
|
|
"conversation_keys": {"keys": {"1": b"k"}},
|
|
"latest_key_version": "1",
|
|
"errors": {},
|
|
}
|
|
|
|
def encrypt_text(self, conversation_id, text, *, attachments=None, **kw):
|
|
if self.fail_encrypt is not None:
|
|
raise self.fail_encrypt
|
|
self.encrypted.append((conversation_id, text, attachments))
|
|
return {
|
|
"message_id": "mid-1",
|
|
"encoded_message_create_event": "ZW5j",
|
|
"encoded_message_event_signature": "c2ln",
|
|
}
|
|
|
|
def encrypt_reply(self, conversation_id, text, reply_to_event, *, attachments=None):
|
|
if self.fail_encrypt is not None:
|
|
raise self.fail_encrypt
|
|
self.replies.append((conversation_id, text, reply_to_event, attachments))
|
|
return {
|
|
"message_id": "mid-r",
|
|
"encoded_message_create_event": "cmVw",
|
|
"encoded_message_event_signature": "c2ln",
|
|
}
|
|
|
|
def encrypt_media(self, plaintext, conversation_key):
|
|
self.media_encrypted.append(bytes(plaintext))
|
|
return b"ENC" + bytes(plaintext)
|
|
|
|
def decrypt_media(self, ciphertext, conversation_key):
|
|
self.media_decrypted.append(bytes(ciphertext))
|
|
assert bytes(ciphertext).startswith(b"ENC")
|
|
return bytes(ciphertext)[3:]
|
|
|
|
def set_signing_keys(self, keys):
|
|
self.signing_keys = list(keys)
|
|
|
|
|
|
class FakeApi:
|
|
"""Stands in for XChatApi — canned responses, records sends."""
|
|
|
|
def __init__(self) -> None:
|
|
self.sent: List[tuple] = []
|
|
self.typing: List[str] = []
|
|
self.reads: List[tuple] = []
|
|
self.uploads: List[tuple] = []
|
|
self.key_changes: List[tuple] = []
|
|
self.media_blobs: Dict[str, bytes] = {}
|
|
self.public_keys: Dict[str, List[Dict[str, Any]]] = {}
|
|
self.events_pages: Dict[str, Dict[str, Any]] = {}
|
|
# Optional multi-page feed: {conv_id: {pagination_token_or_None: page}}
|
|
self.paged_events: Dict[str, Dict[Optional[str], Dict[str, Any]]] = {}
|
|
self.conversations: List[str] = []
|
|
|
|
async def get_my_user(self):
|
|
return {"id": "999"}
|
|
|
|
async def get_public_keys(self, user_id):
|
|
return self.public_keys.get(user_id, [])
|
|
|
|
async def get_conversations(self, *, max_results=100, pagination_token=None):
|
|
return {
|
|
"data": [{"conversation_id": c} for c in self.conversations],
|
|
"meta": {},
|
|
}
|
|
|
|
async def get_events(self, conversation_id, *, max_results=50, pagination_token=None):
|
|
if conversation_id in self.paged_events:
|
|
return self.paged_events[conversation_id].get(pagination_token, {"data": []})
|
|
if pagination_token is not None:
|
|
return {"data": []}
|
|
return self.events_pages.get(conversation_id, {"data": []})
|
|
|
|
async def send_message(self, conversation_id, body):
|
|
self.sent.append((conversation_id, body))
|
|
return {"data": {"message_id": body.get("message_id", ""), "event_id": "evt-echo"}}
|
|
|
|
async def send_typing(self, conversation_id):
|
|
self.typing.append(conversation_id)
|
|
|
|
async def mark_read(self, conversation_id, seen_until_sequence_id):
|
|
self.reads.append((conversation_id, seen_until_sequence_id))
|
|
|
|
async def media_upload(self, conversation_id, encrypted_blob, *, chunk_size=1024 * 1024):
|
|
self.uploads.append((conversation_id, bytes(encrypted_blob)))
|
|
return f"mhk-{len(self.uploads)}"
|
|
|
|
async def media_download(self, conversation_id, media_hash_key):
|
|
return self.media_blobs[media_hash_key]
|
|
|
|
async def add_conversation_keys(self, conversation_id, body):
|
|
self.key_changes.append((conversation_id, body))
|
|
return {"data": {"conversation_id": "111:999", "sequence_id": "sq1"}}
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
def _make_adapter(monkeypatch: pytest.MonkeyPatch, **extra) -> XChatAdapter:
|
|
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "test-token")
|
|
monkeypatch.setenv("XCHAT_USER_ID", "999")
|
|
cfg = PlatformConfig(enabled=True, token="", extra=dict(extra))
|
|
return XChatAdapter(cfg)
|
|
|
|
|
|
def _wire(adapter: XChatAdapter) -> tuple[FakeApi, FakeCrypto]:
|
|
api, crypto = FakeApi(), FakeCrypto()
|
|
adapter._api = api
|
|
adapter._crypto = crypto
|
|
adapter._bot_user_id = "999"
|
|
return api, crypto
|
|
|
|
|
|
def _capture(adapter: XChatAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
|
|
captured: List[MessageEvent] = []
|
|
|
|
async def fake_handle(event: MessageEvent) -> None:
|
|
captured.append(event)
|
|
|
|
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
|
return captured
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_fn / config
|
|
|
|
|
|
def test_check_requirements_needs_token_and_blob(monkeypatch, tmp_path):
|
|
monkeypatch.delenv("XCHAT_ACCESS_TOKEN", raising=False)
|
|
monkeypatch.delenv("XCHAT_PRIVATE_KEYS_B64", raising=False)
|
|
assert check_requirements() is False
|
|
|
|
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "tok")
|
|
assert check_requirements() is False # no key blob
|
|
|
|
monkeypatch.setenv("XCHAT_PRIVATE_KEYS_B64", "YmxvYg==")
|
|
assert check_requirements() is True
|
|
|
|
|
|
def test_env_enablement_seeds_extra(monkeypatch):
|
|
monkeypatch.delenv("XCHAT_ACCESS_TOKEN", raising=False)
|
|
assert _env_enablement() is None
|
|
|
|
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "tok")
|
|
monkeypatch.setenv("XCHAT_USER_ID", "42")
|
|
monkeypatch.setenv("XCHAT_HOME_CHANNEL", "123-456")
|
|
seed = _env_enablement()
|
|
assert seed is not None
|
|
assert seed["access_token"] == "tok"
|
|
assert seed["user_id"] == "42"
|
|
assert seed["home_channel"]["chat_id"] == "123-456"
|
|
|
|
|
|
def test_adapter_reads_config_extra_over_defaults(monkeypatch):
|
|
adapter = _make_adapter(
|
|
monkeypatch,
|
|
poll_interval="30",
|
|
conversation_ids="111-222, g333",
|
|
)
|
|
assert adapter._poll_interval == 30.0
|
|
assert adapter._pinned_conversations == ["111-222", "g333"]
|
|
assert adapter.platform == Platform("xchat")
|
|
|
|
|
|
def test_poll_interval_floor(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch, poll_interval="0.1")
|
|
assert adapter._poll_interval == 2.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mention gating
|
|
|
|
|
|
def test_mention_patterns_json_and_csv():
|
|
pats = _compile_mention_patterns('["^bot\\\\b"]')
|
|
assert pats[0].pattern == "^bot\\b"
|
|
pats = _compile_mention_patterns("alpha, beta")
|
|
assert len(pats) == 2
|
|
# None → defaults
|
|
assert _compile_mention_patterns(None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_mention_gate(monkeypatch):
|
|
monkeypatch.setenv("XCHAT_REQUIRE_MENTION", "true")
|
|
adapter = _make_adapter(monkeypatch)
|
|
_wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
|
|
# Group message without wake word → dropped
|
|
await adapter._dispatch_inbound(
|
|
conv_id="g123", sender_id="5", text="just chatting", message_id="e1", raw={}
|
|
)
|
|
assert captured == []
|
|
|
|
# Group message with wake word → dispatched, wake word stripped
|
|
await adapter._dispatch_inbound(
|
|
conv_id="g123", sender_id="5", text="hermes what time is it", message_id="e2", raw={}
|
|
)
|
|
assert len(captured) == 1
|
|
assert captured[0].text == "what time is it"
|
|
|
|
# DMs are never gated
|
|
await adapter._dispatch_inbound(
|
|
conv_id="111-222", sender_id="5", text="no wake word", message_id="e3", raw={}
|
|
)
|
|
assert len(captured) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_sets_chat_type(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
_wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
|
|
await adapter._dispatch_inbound(
|
|
conv_id="g42", sender_id="7", text="hi", message_id="e1", raw={}
|
|
)
|
|
await adapter._dispatch_inbound(
|
|
conv_id="111-999", sender_id="7", text="hi", message_id="e2", raw={}
|
|
)
|
|
assert captured[0].source.chat_type == "group"
|
|
assert captured[1].source.chat_type == "dm"
|
|
assert captured[0].message_type == MessageType.TEXT
|
|
assert captured[1].source.user_id == "7"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Poll loop mechanics
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backlog_seeds_keys_without_replying(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
|
|
api.events_pages["111-999"] = {
|
|
"data": [
|
|
{"id": "e1", "encoded_event": "AAA", "sender_id": "111"},
|
|
{"id": "e2", "encoded_event": "BBB", "sender_id": "111"},
|
|
]
|
|
}
|
|
await adapter._poll_conversation("111-999")
|
|
|
|
# Backlog: batch-decrypted for keys, nothing dispatched, cursor advanced.
|
|
assert crypto.batch_calls == [["BBB", "AAA"]] # newest-first reversed
|
|
assert captured == []
|
|
assert adapter._cursors["111-999"] == "e1" # newest wire event
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_message_dispatched_after_backlog(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "e1"
|
|
|
|
crypto.decrypt_map["CCC"] = {
|
|
"type": "Message",
|
|
"id": "e3",
|
|
"sender_id": "111",
|
|
"conversation_id": "111:999",
|
|
"content": {"text": "hello agent"},
|
|
}
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e3", "encoded_event": "CCC", "sender_id": "111"}]
|
|
}
|
|
await adapter._poll_conversation("111-999")
|
|
|
|
assert len(captured) == 1
|
|
ev = captured[0]
|
|
assert ev.text == "hello agent"
|
|
# Reply target uses the canonical id embedded in the signed event.
|
|
assert ev.source.chat_id == "111:999"
|
|
assert adapter._cursors["111-999"] == "e3"
|
|
|
|
# Second poll with the same event id → cursor stops it, no double dispatch.
|
|
await adapter._poll_conversation("111-999")
|
|
assert len(captured) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cursor_survives_restart_and_prune(monkeypatch):
|
|
"""The persisted cursor — not the in-memory dedup set — is the duplicate
|
|
guard: a fresh adapter (restart) with a pruned/empty dedup set must not
|
|
re-reply to already-processed events, and must still process newer ones."""
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "0"
|
|
|
|
crypto.decrypt_map["CCC"] = {
|
|
"type": "Message",
|
|
"id": "5",
|
|
"sender_id": "111",
|
|
"conversation_id": "111:999",
|
|
"content": {"text": "first"},
|
|
}
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "5", "encoded_event": "CCC", "sender_id": "111"}]
|
|
}
|
|
await adapter._poll_conversation("111-999")
|
|
assert len(captured) == 1
|
|
|
|
# Simulate a restart: brand-new adapter, empty dedup set, cursor from disk.
|
|
adapter2 = _make_adapter(monkeypatch)
|
|
api2, crypto2 = _wire(adapter2)
|
|
captured2 = _capture(adapter2, monkeypatch)
|
|
assert adapter2._cursors.get("111-999") == "5" # loaded from disk
|
|
|
|
# Old event 5 again + a newer event 7 that arrived while we were down.
|
|
crypto2.decrypt_map["CCC"] = crypto.decrypt_map["CCC"]
|
|
crypto2.decrypt_map["DDD"] = {
|
|
"type": "Message",
|
|
"id": "7",
|
|
"sender_id": "111",
|
|
"conversation_id": "111:999",
|
|
"content": {"text": "while you were down"},
|
|
}
|
|
api2.events_pages["111-999"] = {
|
|
"data": [
|
|
{"id": "7", "encoded_event": "DDD", "sender_id": "111"},
|
|
{"id": "5", "encoded_event": "CCC", "sender_id": "111"},
|
|
]
|
|
}
|
|
await adapter2._poll_conversation("111-999")
|
|
|
|
# Only the new event dispatched — no re-reply to 5, no backlog swallow of 7.
|
|
assert [ev.text for ev in captured2] == ["while you were down"]
|
|
assert adapter2._cursors["111-999"] == "7"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_burst_larger_than_one_page_is_paginated(monkeypatch):
|
|
"""A burst of more than max_results events between polls must not drop
|
|
the overflow — the poll pages back until it reaches the cursor."""
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "10"
|
|
|
|
for n in (11, 12, 13, 14):
|
|
crypto.decrypt_map[f"E{n}"] = {
|
|
"type": "Message",
|
|
"id": str(n),
|
|
"sender_id": "111",
|
|
"conversation_id": "111:999",
|
|
"content": {"text": f"msg {n}"},
|
|
}
|
|
# Newest-first across two pages: page1 = 14,13 (with next_token), page2 = 12,11,10.
|
|
api.paged_events["111-999"] = {
|
|
None: {
|
|
"data": [
|
|
{"id": "14", "encoded_event": "E14", "sender_id": "111"},
|
|
{"id": "13", "encoded_event": "E13", "sender_id": "111"},
|
|
],
|
|
"meta": {"next_token": "p2"},
|
|
},
|
|
"p2": {
|
|
"data": [
|
|
{"id": "12", "encoded_event": "E12", "sender_id": "111"},
|
|
{"id": "11", "encoded_event": "E11", "sender_id": "111"},
|
|
{"id": "10", "encoded_event": "E10", "sender_id": "111"},
|
|
],
|
|
"meta": {},
|
|
},
|
|
}
|
|
await adapter._poll_conversation("111-999")
|
|
|
|
# All four new events dispatched oldest-first; the pre-cursor event 10 skipped.
|
|
assert [ev.text for ev in captured] == ["msg 11", "msg 12", "msg 13", "msg 14"]
|
|
assert adapter._cursors["111-999"] == "14"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_edit_dispatched(monkeypatch):
|
|
"""The feed sometimes returns an edited message ONLY as the edit event —
|
|
skipping edits would make the message invisible to the bot forever."""
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "e1"
|
|
|
|
crypto.decrypt_map["EDIT"] = {
|
|
"type": "MessageEdit",
|
|
"id": "e6",
|
|
"sender_id": "111",
|
|
"conversation_id": "111:999",
|
|
"content": {"text": "edited text"},
|
|
}
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e6", "encoded_event": "EDIT", "sender_id": "111"}]
|
|
}
|
|
await adapter._poll_conversation("111-999")
|
|
|
|
assert len(captured) == 1
|
|
assert captured[0].text == "edited text"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_own_messages_filtered(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "e1"
|
|
|
|
crypto.decrypt_map["DDD"] = {
|
|
"type": "Message",
|
|
"id": "e4",
|
|
"sender_id": "999", # the bot itself
|
|
"content": {"text": "echo of our own reply"},
|
|
}
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e4", "encoded_event": "DDD", "sender_id": "999"}]
|
|
}
|
|
await adapter._poll_conversation("111-999")
|
|
assert captured == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_keychange_routes_through_batch(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "e1"
|
|
|
|
crypto.decrypt_map["KEY"] = {"type": "KeyChange", "id": "e5"}
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e5", "encoded_event": "KEY", "sender_id": "111"}]
|
|
}
|
|
await adapter._poll_conversation("111-999")
|
|
|
|
assert crypto.batch_calls == [["KEY"]]
|
|
assert captured == []
|
|
assert adapter._conversation_keys["111-999"] == {"1": b"k"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_signing_keys_fetched_once_per_sender(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
api.public_keys["111"] = [
|
|
{
|
|
"public_key_version": "3",
|
|
"signing_public_key": "SPK",
|
|
"public_key": "IPK",
|
|
"identity_public_key_signature": "SIG",
|
|
}
|
|
]
|
|
events = [{"id": "e1", "sender_id": "111"}]
|
|
await adapter._register_signing_keys(events)
|
|
await adapter._register_signing_keys(events) # second call — cached
|
|
|
|
assert len(crypto.signing_keys) == 1
|
|
entry = crypto.signing_keys[0]
|
|
assert entry["user_id"] == "111"
|
|
assert entry["public_key"] == "SPK"
|
|
assert entry["identity_public_key"] == "IPK"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_adds_conversations(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, _ = _wire(adapter)
|
|
api.conversations = ["111-222", "g333"]
|
|
await adapter._discover_conversations()
|
|
assert adapter._conversations == {"111-222", "g333"}
|
|
|
|
|
|
def test_dedup_prune_bounds_memory(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
now = time.time()
|
|
for i in range(xchat_adapter.DEDUP_MAX_SIZE + 100):
|
|
adapter._seen_event_ids[f"e{i}"] = now + i
|
|
adapter._prune_dedup()
|
|
assert len(adapter._seen_event_ids) <= xchat_adapter.DEDUP_MAX_SIZE
|
|
# Newest entries survive the prune.
|
|
assert f"e{xchat_adapter.DEDUP_MAX_SIZE + 99}" in adapter._seen_event_ids
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Outbound
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_encrypts_and_posts(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
|
|
result = await adapter.send("111:999", "hi there")
|
|
assert result.success
|
|
assert result.message_id == "mid-1"
|
|
assert crypto.encrypted == [("111:999", "hi there", None)]
|
|
conv, body = api.sent[0]
|
|
assert conv == "111:999"
|
|
assert body["encoded_message_create_event"] == "ZW5j"
|
|
# Echo suppression: returned event id marked as seen.
|
|
assert "evt-echo" in adapter._seen_event_ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_without_conversation_key(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
_, crypto = _wire(adapter)
|
|
crypto.fail_encrypt = ValueError("no key")
|
|
|
|
result = await adapter.send("111:999", "hi")
|
|
assert not result.success
|
|
assert "conversation key" in (result.error or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_disconnected(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
result = await adapter.send("111:999", "hi")
|
|
assert not result.success
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_chat_info_types(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
assert (await adapter.get_chat_info("g123"))["type"] == "group"
|
|
assert (await adapter.get_chat_info("111-222"))["type"] == "dm"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Crypto wrapper (fake Chat object — no native SDK)
|
|
|
|
|
|
class _FakePayload:
|
|
message_id = "m1"
|
|
encrypted_content = "ENC"
|
|
encoded_event_signature = "SIG"
|
|
|
|
|
|
class _FakeChat:
|
|
def __init__(self) -> None:
|
|
self.identity = None
|
|
self.cache = None
|
|
self.imported = None
|
|
|
|
def import_keys(self, blob, version=None):
|
|
self.imported = (blob, version)
|
|
|
|
def set_identity(self, user_id, version):
|
|
self.identity = (user_id, version)
|
|
|
|
def set_cache_keys(self, enabled):
|
|
self.cache = enabled
|
|
|
|
def set_signing_keys(self, keys):
|
|
self.signing = keys
|
|
|
|
def encrypt_message(self, conversation_id, text):
|
|
return _FakePayload()
|
|
|
|
def decrypt_event(self, event_b64, conversation_keys, signing_keys):
|
|
return {"type": "Message", "content": {"text": "plain"}}
|
|
|
|
def decrypt_events(self, events, signing_keys):
|
|
return {"messages": [{"event": {"type": "Message"}}], "conversation_keys": {}, "errors": {}}
|
|
|
|
|
|
def test_crypto_load_keys_and_identity():
|
|
crypto = XChatCrypto(chat=_FakeChat())
|
|
crypto.load_keys("YmxvYg==", "7") # b64("blob")
|
|
assert crypto.chat.imported == (b"blob", "7")
|
|
assert crypto.signing_key_version == "7"
|
|
crypto.set_identity("42")
|
|
assert crypto.chat.identity == ("42", "7")
|
|
|
|
|
|
def test_crypto_encrypt_shapes_send_body():
|
|
crypto = XChatCrypto(chat=_FakeChat())
|
|
body = crypto.encrypt_text("1:2", "hello")
|
|
assert body == {
|
|
"message_id": "m1",
|
|
"encoded_message_create_event": "ENC",
|
|
"encoded_message_event_signature": "SIG",
|
|
}
|
|
|
|
|
|
def test_message_text_extraction():
|
|
assert message_text({"type": "Message", "content": {"text": "x"}}) == "x"
|
|
assert message_text({"type": "KeyChange"}) is None
|
|
assert message_text({"type": "Message", "content": {}}) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry integration
|
|
|
|
|
|
def test_platform_registry_entry_parity():
|
|
"""Every parity knob must be populated on the registered entry."""
|
|
from gateway.platform_registry import PlatformEntry
|
|
|
|
captured: Dict[str, Any] = {}
|
|
|
|
class Ctx:
|
|
class manifest:
|
|
name = "xchat-platform"
|
|
|
|
def register_platform(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
def register_cli_command(self, **kwargs):
|
|
captured["cli"] = kwargs
|
|
|
|
xchat_adapter.register(Ctx())
|
|
|
|
assert captured["name"] == "xchat"
|
|
assert captured["allowed_users_env"] == "XCHAT_ALLOWED_USERS"
|
|
assert captured["allow_all_env"] == "XCHAT_ALLOW_ALL_USERS"
|
|
assert captured["cron_deliver_env_var"] == "XCHAT_HOME_CHANNEL"
|
|
assert callable(captured["standalone_sender_fn"])
|
|
assert callable(captured["setup_fn"])
|
|
assert callable(captured["env_enablement_fn"])
|
|
assert captured["platform_hint"]
|
|
assert captured["max_message_length"] > 0
|
|
assert captured["cli"]["name"] == "xchat"
|
|
# The kwargs must construct a valid PlatformEntry.
|
|
entry_kwargs = {k: v for k, v in captured.items() if k != "cli"}
|
|
entry = PlatformEntry(**entry_kwargs)
|
|
assert entry.name == "xchat"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Standalone send (config-error paths — no network)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_requires_token(monkeypatch):
|
|
monkeypatch.delenv("XCHAT_ACCESS_TOKEN", raising=False)
|
|
monkeypatch.delenv("XCHAT_PRIVATE_KEYS_B64", raising=False)
|
|
cfg = PlatformConfig(enabled=True, extra={})
|
|
out = await xchat_adapter._standalone_send(cfg, "111", "msg")
|
|
assert "XCHAT_ACCESS_TOKEN" in out["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_requires_key_blob(monkeypatch):
|
|
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "tok")
|
|
monkeypatch.delenv("XCHAT_PRIVATE_KEYS_B64", raising=False)
|
|
cfg = PlatformConfig(enabled=True, extra={})
|
|
out = await xchat_adapter._standalone_send(cfg, "111", "msg")
|
|
assert "private-key blob" in out["error"]
|
|
|
|
|
|
class StrictCrypto:
|
|
"""Refuses to seed / use conversation keys until set_signing_keys is
|
|
called — mirrors the real SDK, where KeyChange verification (and thus
|
|
key seeding) is impossible without the participants' signing keys."""
|
|
|
|
def __init__(self) -> None:
|
|
self.signing_keys: List[Dict[str, str]] = []
|
|
self.keys_seeded = False
|
|
self.encrypted: List[tuple] = []
|
|
|
|
def load_keys(self, blob, version="1"):
|
|
pass
|
|
|
|
def set_identity(self, user_id):
|
|
pass
|
|
|
|
def set_cache_keys(self, enabled=True):
|
|
pass
|
|
|
|
def set_signing_keys(self, keys):
|
|
self.signing_keys = list(keys)
|
|
|
|
def decrypt_batch(self, events_b64):
|
|
if not self.signing_keys:
|
|
# Real SDK: KeyChange verification fails, no keys extracted.
|
|
return {"messages": [], "conversation_keys": {}, "errors": {"all": "unverified"}}
|
|
self.keys_seeded = True
|
|
return {
|
|
"messages": [{"event": {"conversation_id": "111:999"}}],
|
|
"conversation_keys": {"keys": {"1": b"k"}},
|
|
"errors": {},
|
|
}
|
|
|
|
def encrypt_text(self, conversation_id, text, *, attachments=None, **kw):
|
|
if not self.keys_seeded:
|
|
raise ValueError("no verified conversation key")
|
|
self.encrypted.append((conversation_id, text))
|
|
return {
|
|
"message_id": "mid-9",
|
|
"encoded_message_create_event": "ZW5j",
|
|
"encoded_message_event_signature": "c2ln",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_registers_signing_keys_before_decrypt(monkeypatch):
|
|
"""Full standalone-send path against a strict crypto fake: without the
|
|
sender's signing keys pushed FIRST, key seeding fails and the send would
|
|
error — the regression the original FakeCrypto (keys unconditionally
|
|
handed out) let slip through."""
|
|
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "tok")
|
|
monkeypatch.setenv("XCHAT_PRIVATE_KEYS_B64", "YmxvYg==")
|
|
monkeypatch.setenv("XCHAT_USER_ID", "999")
|
|
|
|
api = FakeApi()
|
|
api.public_keys["111"] = [
|
|
{
|
|
"public_key_version": "3",
|
|
"signing_public_key": "SPK",
|
|
"public_key": "IPK",
|
|
"identity_public_key_signature": "SIG",
|
|
}
|
|
]
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e1", "encoded_event": "KEYCHG", "sender_id": "111"}]
|
|
}
|
|
crypto = StrictCrypto()
|
|
monkeypatch.setattr(xchat_adapter, "XChatApi", lambda *a, **kw: api)
|
|
monkeypatch.setattr(xchat_adapter, "XChatCrypto", lambda: crypto)
|
|
|
|
cfg = PlatformConfig(enabled=True, extra={})
|
|
out = await xchat_adapter._standalone_send(cfg, "111-999", "hello")
|
|
|
|
assert out.get("success") is True, out
|
|
# Signing keys were pushed before decrypt_batch could seed the key.
|
|
assert crypto.signing_keys and crypto.signing_keys[0]["user_id"] == "111"
|
|
assert crypto.keys_seeded
|
|
# Canonical conversation id (from the decrypted backlog) used for the send.
|
|
assert out["chat_id"] == "111:999"
|
|
assert api.sent and api.sent[0][0] == "111:999"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_no_key_without_signing_roster(monkeypatch):
|
|
"""If the sender's public keys can't be fetched, the key never seeds and
|
|
the send reports the no-verified-key error instead of crashing."""
|
|
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "tok")
|
|
monkeypatch.setenv("XCHAT_PRIVATE_KEYS_B64", "YmxvYg==")
|
|
monkeypatch.setenv("XCHAT_USER_ID", "999")
|
|
|
|
api = FakeApi() # no public_keys registered → empty roster
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e1", "encoded_event": "KEYCHG", "sender_id": "111"}]
|
|
}
|
|
crypto = StrictCrypto()
|
|
monkeypatch.setattr(xchat_adapter, "XChatApi", lambda *a, **kw: api)
|
|
monkeypatch.setattr(xchat_adapter, "XChatCrypto", lambda: crypto)
|
|
|
|
cfg = PlatformConfig(enabled=True, extra={})
|
|
out = await xchat_adapter._standalone_send(cfg, "111-999", "hello")
|
|
assert "no verified conversation key" in out.get("error", "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Media, replies, read receipts, key-events meta
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inbound_attachment_downloaded_and_decrypted(monkeypatch, tmp_path):
|
|
"""Encrypted inbound media is downloaded, decrypted with the event's
|
|
key version, cached locally, and surfaced on the MessageEvent."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "e1"
|
|
adapter._conversation_keys["111-999"] = {"1": b"k"}
|
|
adapter._latest_key_version["111-999"] = "1"
|
|
|
|
# PNG magic so mime sniffing (real chatxdk helper unavailable → fallback)
|
|
png = b"\x89PNG\r\n\x1a\n" + b"0" * 32
|
|
api.media_blobs["mhk-img"] = b"ENC" + png
|
|
crypto.decrypt_map["MED"] = {
|
|
"type": "Message",
|
|
"id": "e9",
|
|
"sender_id": "111",
|
|
"conversation_id": "111:999",
|
|
"key_version": "1",
|
|
"content": {
|
|
"text": "look at this",
|
|
"attachments": [{"media_hash_key": "mhk-img", "filename": "photo.png"}],
|
|
},
|
|
}
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e9", "encoded_event": "MED", "sender_id": "111"}]
|
|
}
|
|
# detect_mime_type needs the native SDK — stub it.
|
|
monkeypatch.setattr(xchat_adapter, "detect_mime_type", lambda b: "image/png")
|
|
|
|
await adapter._poll_conversation("111-999")
|
|
|
|
assert len(captured) == 1
|
|
ev = captured[0]
|
|
assert ev.text == "look at this"
|
|
assert len(ev.media_urls) == 1
|
|
assert ev.media_types == ["image/png"]
|
|
from pathlib import Path as _P
|
|
|
|
assert _P(ev.media_urls[0]).read_bytes() == png
|
|
assert crypto.media_decrypted == [b"ENC" + png]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_media_encrypt_upload_attach(monkeypatch, tmp_path):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
adapter._conversation_keys["111:999"] = {"2": b"k2"}
|
|
adapter._latest_key_version["111:999"] = "2"
|
|
|
|
f = tmp_path / "doc.pdf"
|
|
f.write_bytes(b"%PDF-fake")
|
|
monkeypatch.setattr(xchat_adapter, "detect_image_dimensions", lambda b: None)
|
|
|
|
result = await adapter.send_document("111:999", str(f))
|
|
assert result.success, result.error
|
|
# Encrypted before upload...
|
|
assert crypto.media_encrypted == [b"%PDF-fake"]
|
|
assert api.uploads and api.uploads[0][1] == b"ENC%PDF-fake"
|
|
# ...and the send body carried the attachment descriptor.
|
|
conv, text, attachments = crypto.encrypted[0]
|
|
assert attachments and attachments[0]["media_hash_key"] == "mhk-1"
|
|
assert attachments[0]["filename"] == "doc.pdf"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_media_without_key_fails_cleanly(monkeypatch, tmp_path):
|
|
adapter = _make_adapter(monkeypatch)
|
|
_wire(adapter)
|
|
f = tmp_path / "a.png"
|
|
f.write_bytes(b"x")
|
|
result = await adapter.send_image("111:999", str(f))
|
|
assert not result.success
|
|
assert "conversation key" in (result.error or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_threaded_reply_uses_cached_event(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
target = {"type": "Message", "id": "e5", "sender_id": "111", "content": {"text": "orig"}}
|
|
adapter._cache_event("111:999", "e5", target)
|
|
|
|
result = await adapter.send("111:999", "threaded answer", reply_to="e5")
|
|
assert result.success
|
|
assert crypto.replies == [("111:999", "threaded answer", target, None)]
|
|
assert crypto.encrypted == [] # took the reply path, not plain encrypt
|
|
|
|
# Unknown reply target falls back to a plain send.
|
|
result = await adapter.send("111:999", "plain", reply_to="nope")
|
|
assert result.success
|
|
assert crypto.encrypted == [("111:999", "plain", None)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_key_events_absorbed_before_messages(monkeypatch):
|
|
"""KeyChange events arrive in meta.conversation_key_events — they must
|
|
seed the key cache before this poll's messages are decrypted."""
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "e1"
|
|
|
|
crypto.decrypt_map["MSG"] = {
|
|
"type": "Message",
|
|
"id": "e2",
|
|
"sender_id": "111",
|
|
"conversation_id": "111:999",
|
|
"content": {"text": "post-rotation"},
|
|
}
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e2", "encoded_event": "MSG", "sender_id": "111"}],
|
|
"meta": {"conversation_key_events": ["KEYCHG"]},
|
|
}
|
|
await adapter._poll_conversation("111-999")
|
|
|
|
assert crypto.batch_calls == [["KEYCHG"]]
|
|
assert adapter._conversation_keys["111-999"] == {"1": b"k"}
|
|
assert adapter._latest_key_version["111-999"] == "1"
|
|
assert [ev.text for ev in captured] == ["post-rotation"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_receipts_opt_in(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
_capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "e1"
|
|
crypto.decrypt_map["CCC"] = {
|
|
"type": "Message",
|
|
"id": "e3",
|
|
"sender_id": "111",
|
|
"conversation_id": "111:999",
|
|
"content": {"text": "hi"},
|
|
}
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e3", "encoded_event": "CCC", "sender_id": "111", "sequence_id": "sq3"}]
|
|
}
|
|
|
|
# Default: off.
|
|
await adapter._poll_conversation("111-999")
|
|
assert api.reads == []
|
|
|
|
# Opt in.
|
|
adapter2 = _make_adapter(monkeypatch, send_read_receipts=True)
|
|
api2, crypto2 = _wire(adapter2)
|
|
_capture(adapter2, monkeypatch)
|
|
adapter2._cursors["111-888"] = "e1"
|
|
crypto2.decrypt_map["CCC"] = crypto.decrypt_map["CCC"]
|
|
api2.events_pages["111-888"] = api.events_pages["111-999"]
|
|
await adapter2._poll_conversation("111-888")
|
|
assert api2.reads == [("111-888", "sq3")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inbound_reply_context_propagates(monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
api, crypto = _wire(adapter)
|
|
captured = _capture(adapter, monkeypatch)
|
|
adapter._cursors["111-999"] = "e1"
|
|
crypto.decrypt_map["RPL"] = {
|
|
"type": "Message",
|
|
"id": "e7",
|
|
"sender_id": "111",
|
|
"conversation_id": "111:999",
|
|
"content": {"text": "and this one?"},
|
|
"reply_to": {"id": "e4", "sender_id": "999", "text": "earlier bot answer"},
|
|
}
|
|
api.events_pages["111-999"] = {
|
|
"data": [{"id": "e7", "encoded_event": "RPL", "sender_id": "111"}]
|
|
}
|
|
await adapter._poll_conversation("111-999")
|
|
|
|
ev = captured[0]
|
|
assert ev.reply_to_message_id == "e4"
|
|
assert ev.reply_to_text == "earlier bot answer"
|
|
assert ev.reply_to_is_own_message is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_new_conversation_handshake(monkeypatch):
|
|
"""Standalone send to a bare user id with no existing conversation
|
|
performs the verified key handshake, then sends under the fresh key."""
|
|
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "tok")
|
|
monkeypatch.setenv("XCHAT_PRIVATE_KEYS_B64", "YmxvYg==")
|
|
monkeypatch.setenv("XCHAT_USER_ID", "999")
|
|
|
|
api = FakeApi()
|
|
api.public_keys["999"] = [
|
|
{"public_key_version": "1", "public_key": "BOT-IPK",
|
|
"signing_public_key": "BOT-SPK", "identity_public_key_signature": "BOT-SIG"}
|
|
]
|
|
api.public_keys["111"] = [
|
|
{"public_key_version": "2", "public_key": "USR-IPK",
|
|
"signing_public_key": "USR-SPK", "identity_public_key_signature": "USR-SIG"}
|
|
]
|
|
|
|
class HandshakeCrypto(FakeCrypto):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.bindings: List[tuple] = []
|
|
self.prepared = False
|
|
self.explicit_key_used = None
|
|
|
|
def load_keys(self, blob, version="1"):
|
|
pass
|
|
|
|
def set_identity(self, user_id):
|
|
pass
|
|
|
|
def set_cache_keys(self, enabled=True):
|
|
pass
|
|
|
|
def verify_key_binding(self, ipk, spk, sig):
|
|
self.bindings.append((ipk, spk, sig))
|
|
return True
|
|
|
|
def prepare_conversation_key_change(self, public_keys, *, conversation_id=None):
|
|
self.prepared = True
|
|
return {
|
|
"body": {"conversation_key_version": "1",
|
|
"conversation_participant_keys": [], "action_signatures": []},
|
|
"conversation_key": b"fresh-key",
|
|
"conversation_key_version": "1",
|
|
}
|
|
|
|
def encrypt_text(self, conversation_id, text, *, attachments=None,
|
|
conversation_key=None, conversation_key_version=None):
|
|
if conversation_key is None:
|
|
raise ValueError("no verified conversation key")
|
|
self.explicit_key_used = conversation_key
|
|
return {
|
|
"message_id": "mid-new",
|
|
"encoded_message_create_event": "bmV3",
|
|
"encoded_message_event_signature": "c2ln",
|
|
}
|
|
|
|
crypto = HandshakeCrypto()
|
|
monkeypatch.setattr(xchat_adapter, "XChatApi", lambda *a, **kw: api)
|
|
monkeypatch.setattr(xchat_adapter, "XChatCrypto", lambda: crypto)
|
|
|
|
cfg = PlatformConfig(enabled=True, extra={})
|
|
out = await xchat_adapter._standalone_send(cfg, "111", "hello new friend")
|
|
|
|
assert out.get("success") is True, out
|
|
# Both parties' bindings verified, key change POSTed, canonical id adopted.
|
|
assert len(crypto.bindings) == 2
|
|
assert crypto.prepared
|
|
assert api.key_changes and api.key_changes[0][0] == "111"
|
|
assert crypto.explicit_key_used == b"fresh-key"
|
|
assert out["chat_id"] == "111:999"
|