3840 lines
143 KiB
Python
3840 lines
143 KiB
Python
"""Tests for the Buzz platform adapter plugin."""
|
|
|
|
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from gateway.platforms.base import CachedMedia, MessageType
|
|
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
|
|
from gateway.platforms.base import MessageType
|
|
|
|
# Load plugins/platforms/buzz/adapter.py under a unique module name
|
|
# (plugin_adapter_buzz) so it cannot collide with other plugin adapters
|
|
# loaded by sibling tests in the same xdist worker.
|
|
_buzz_mod = load_plugin_adapter("buzz")
|
|
|
|
BuzzAdapter = _buzz_mod.BuzzAdapter
|
|
hex_to_npub = _buzz_mod.hex_to_npub
|
|
npub_to_hex = _buzz_mod.npub_to_hex
|
|
_normalize_user_ref = _buzz_mod._normalize_user_ref
|
|
_cli_error_message = _buzz_mod._cli_error_message
|
|
_resolve_private_key = _buzz_mod._resolve_private_key
|
|
_resolve_auth_tag = _buzz_mod._resolve_auth_tag
|
|
_event_reply_parent_id = _buzz_mod._event_reply_parent_id
|
|
check_requirements = _buzz_mod.check_requirements
|
|
validate_config = _buzz_mod.validate_config
|
|
register = _buzz_mod.register
|
|
_env_enablement = _buzz_mod._env_enablement
|
|
_standalone_send = _buzz_mod._standalone_send
|
|
|
|
# Real key pair (Chip's public identity — public information, not a secret)
|
|
SELF_PUBKEY = "9fd5c7ba6d3ef224da78f541e0fcb9c50f72cc63edb19aae76ac6a0474dfa860"
|
|
SELF_NPUB = "npub1nl2u0wnd8mezfknc74q7pl9ec58h9nrrakce4tnk434qgaxl4psqe5twr6"
|
|
OTHER_PUBKEY = "a" * 64
|
|
AGENT_PUBKEY = "b" * 64
|
|
CHANNEL = "ccc2bc1a-7a82-5a8f-8c4e-57a070cbe7cd"
|
|
# Real DM conversation as materialized by a hosted relay: `dms list` returns
|
|
# [] for it (#68871) while `channels list` shows it as name "DM", empty
|
|
# description, indistinguishable from a channel except via message p-tags.
|
|
DM_CHANNEL = "6468cc16-a114-4f23-8b8c-02c1655cbf6b"
|
|
|
|
_ENV_VARS = (
|
|
"BUZZ_RELAY_URL",
|
|
"BUZZ_PRIVATE_KEY",
|
|
"BUZZ_CHANNELS",
|
|
"BUZZ_HOME_CHANNEL",
|
|
"BUZZ_ALLOWED_USERS",
|
|
"BUZZ_REACTION_ONLY_USERS",
|
|
"BUZZ_ALLOW_ALL_USERS",
|
|
"BUZZ_POLL_INTERVAL",
|
|
"BUZZ_AUTH_TAG",
|
|
"BUZZ_CLI_PATH",
|
|
"BUZZ_CREDENTIALS_FILE",
|
|
"BUZZ_AUTH_TAG",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(monkeypatch, tmp_path):
|
|
"""Keep tests hermetic: no ambient Buzz env vars or real credentials."""
|
|
for var in _ENV_VARS:
|
|
monkeypatch.delenv(var, raising=False)
|
|
monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path / "no-creds")
|
|
yield
|
|
|
|
|
|
def _event(event_id, pubkey=OTHER_PUBKEY, content="hello", created_at=1000, kind=9):
|
|
return {
|
|
"id": event_id,
|
|
"pubkey": pubkey,
|
|
"content": content,
|
|
"created_at": created_at,
|
|
"kind": kind,
|
|
"tags": [["h", CHANNEL]],
|
|
}
|
|
|
|
|
|
def _make_adapter(extra=None):
|
|
from gateway.config import PlatformConfig
|
|
|
|
cfg = PlatformConfig(enabled=True, extra={"relay_url": "https://test.relay", **(extra or {})})
|
|
adapter = BuzzAdapter(cfg)
|
|
adapter._self_pubkey = SELF_PUBKEY
|
|
adapter._self_npub = SELF_NPUB
|
|
adapter._display_name = "Chip"
|
|
adapter._private_key = "nsec1test"
|
|
# GatewayRunner installs this callback before intake starts. Attachment
|
|
# tests are authorized by default and override the callback at the boundary.
|
|
adapter.set_authorization_check(lambda *_args: True)
|
|
return adapter
|
|
|
|
|
|
class _ScriptedCli:
|
|
"""Fake ``_run_cli`` that routes on the buzz subcommand and records calls."""
|
|
|
|
def __init__(self):
|
|
self.responses = {} # (group, cmd) -> list of (code, stdout, stderr)
|
|
self.calls = []
|
|
|
|
def script(self, group, cmd, payload, code=0, stderr=""):
|
|
stdout = payload if isinstance(payload, str) else json.dumps(payload)
|
|
self.responses.setdefault((group, cmd), []).append((code, stdout, stderr))
|
|
|
|
async def __call__(self, args, *, input_text=None):
|
|
self.calls.append((list(args), input_text))
|
|
queue = self.responses.get((args[0], args[1]), [])
|
|
if len(queue) > 1:
|
|
return queue.pop(0)
|
|
if queue:
|
|
return queue[0]
|
|
return 0, "[]", ""
|
|
|
|
|
|
# ── bech32 / identity helpers ─────────────────────────────────────────────
|
|
|
|
|
|
class TestBech32Helpers:
|
|
|
|
def test_hex_to_npub_known_pair(self):
|
|
assert hex_to_npub(SELF_PUBKEY) == SELF_NPUB
|
|
|
|
def test_npub_to_hex_known_pair(self):
|
|
assert npub_to_hex(SELF_NPUB) == SELF_PUBKEY
|
|
|
|
|
|
# ── Adapter init / config precedence ──────────────────────────────────────
|
|
|
|
|
|
class TestBuzzAdapterInit:
|
|
|
|
|
|
def test_init_from_config_extra(self):
|
|
from gateway.config import PlatformConfig
|
|
cfg = PlatformConfig(
|
|
enabled=True,
|
|
extra={
|
|
"relay_url": "https://cfg.relay",
|
|
"channels": ["ccc"],
|
|
"poll_interval": 2,
|
|
"home_channel": "ccc",
|
|
},
|
|
)
|
|
adapter = BuzzAdapter(cfg)
|
|
assert adapter.relay_url == "https://cfg.relay"
|
|
assert adapter.channels == ["ccc"]
|
|
assert adapter.poll_interval == 2.0
|
|
assert adapter.home_channel == "ccc"
|
|
|
|
def test_env_overrides_config(self, monkeypatch):
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://env.relay")
|
|
from gateway.config import PlatformConfig
|
|
adapter = BuzzAdapter(PlatformConfig(enabled=True, extra={"relay_url": "https://cfg.relay"}))
|
|
assert adapter.relay_url == "https://env.relay"
|
|
|
|
|
|
# ── Multiplex secondary-profile scope (#98738) ─────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def multiplex_scope():
|
|
"""Install multiplex + a secondary-profile secret scope; restore after."""
|
|
|
|
tokens = []
|
|
|
|
def install(scope=None):
|
|
from agent.secret_scope import set_multiplex_active, set_secret_scope
|
|
|
|
set_multiplex_active(True)
|
|
tokens.append(set_secret_scope(scope or {}))
|
|
return tokens[-1]
|
|
|
|
yield install
|
|
|
|
from agent.secret_scope import reset_secret_scope, set_multiplex_active
|
|
|
|
for token in reversed(tokens):
|
|
reset_secret_scope(token)
|
|
set_multiplex_active(False)
|
|
|
|
|
|
@pytest.fixture
|
|
def default_profile_env(monkeypatch):
|
|
"""The default profile's YAML-to-env bridge output in os.environ."""
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://default.relay")
|
|
monkeypatch.setenv("BUZZ_CHANNELS", "chan-a,chan-b,chan-c")
|
|
monkeypatch.setenv("BUZZ_HOME_CHANNEL", "chan-a")
|
|
monkeypatch.setenv("BUZZ_POLL_INTERVAL", "9")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", "/default/bin/buzz")
|
|
monkeypatch.setenv("BUZZ_TRANSPORT", "poll")
|
|
monkeypatch.setenv("BUZZ_ALLOWED_USERS", "default-user-npub")
|
|
monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", "/default/creds.json")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1default")
|
|
monkeypatch.setenv("BUZZ_AUTH_TAG", '["auth","default-profile-tag","","x"]')
|
|
|
|
|
|
class TestMultiplexProfileScope:
|
|
|
|
def test_secondary_extra_wins_over_default_profile_env(
|
|
self, multiplex_scope, default_profile_env, tmp_path
|
|
):
|
|
"""The secondary profile's PlatformConfig is authoritative (#98738)."""
|
|
from gateway.config import PlatformConfig
|
|
|
|
cli = tmp_path / "buzz"
|
|
cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
multiplex_scope()
|
|
cfg = PlatformConfig(
|
|
enabled=True,
|
|
extra={
|
|
"relay_url": "https://profile.relay",
|
|
"channels": ["pchan"],
|
|
"home_channel": "pchan",
|
|
"poll_interval": 2,
|
|
"cli_path": str(cli),
|
|
"transport": "websocket",
|
|
"allowed_users": [SELF_NPUB],
|
|
},
|
|
)
|
|
adapter = BuzzAdapter(cfg)
|
|
assert adapter.relay_url == "https://profile.relay"
|
|
assert adapter.channels == ["pchan"]
|
|
assert adapter.home_channel == "pchan"
|
|
assert adapter.poll_interval == 2.0
|
|
assert adapter.cli_path == str(cli)
|
|
assert adapter.transport == "websocket"
|
|
assert adapter._allowed_pubkeys == {SELF_PUBKEY}
|
|
|
|
def test_secondary_missing_keys_fail_closed(
|
|
self, multiplex_scope, default_profile_env
|
|
):
|
|
"""Keys absent from the profile's config must NOT borrow the default
|
|
profile's bridged env values — that would connect this adapter to the
|
|
default profile's relay and watch its channels."""
|
|
from gateway.config import PlatformConfig
|
|
|
|
multiplex_scope()
|
|
adapter = BuzzAdapter(PlatformConfig(enabled=True, extra={}))
|
|
assert adapter.relay_url == ""
|
|
assert adapter.channels == []
|
|
assert adapter.home_channel == ""
|
|
assert adapter.poll_interval == _buzz_mod._DEFAULT_POLL_INTERVAL
|
|
assert adapter.transport == "auto"
|
|
assert adapter._allowed_pubkeys == set()
|
|
|
|
def test_secondary_credentials_file_not_borrowed(
|
|
self, multiplex_scope, default_profile_env, tmp_path, monkeypatch
|
|
):
|
|
"""BUZZ_CREDENTIALS_FILE in env points at the DEFAULT profile's key
|
|
file; the scoped adapter must not read the default identity's key."""
|
|
default_creds = tmp_path / "default-creds.json"
|
|
default_creds.write_text(
|
|
json.dumps({"nsec": "nsec1default-identity"}), encoding="utf-8"
|
|
)
|
|
monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(default_creds))
|
|
multiplex_scope()
|
|
# Scope has no key: the profile is unconfigured and must fail closed
|
|
# to "" rather than resolving the default profile's credentials.
|
|
assert _buzz_mod._resolve_private_key({}) == ""
|
|
|
|
def test_default_profile_unscoped_keeps_env_precedence(
|
|
self, monkeypatch, default_profile_env
|
|
):
|
|
"""Multiplex ON but no scope (the DEFAULT profile constructs
|
|
unscoped): env is its own bridge output and still wins."""
|
|
from agent.secret_scope import set_multiplex_active
|
|
from gateway.config import PlatformConfig
|
|
|
|
set_multiplex_active(True)
|
|
try:
|
|
adapter = BuzzAdapter(
|
|
PlatformConfig(enabled=True, extra={"relay_url": "https://cfg.relay"})
|
|
)
|
|
finally:
|
|
set_multiplex_active(False)
|
|
assert adapter.relay_url == "https://default.relay"
|
|
|
|
def test_check_requirements_scoped_reads_profile_config(
|
|
self, multiplex_scope, default_profile_env, tmp_path
|
|
):
|
|
"""The gate must consult the profile's own config.yaml + secret scope,
|
|
not the default profile's env values."""
|
|
import yaml
|
|
from hermes_constants import (
|
|
reset_hermes_home_override,
|
|
set_hermes_home_override,
|
|
)
|
|
|
|
creds = tmp_path / "creds.json"
|
|
creds.write_text(json.dumps({"nsec": "nsec1profile"}), encoding="utf-8")
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"gateway": {
|
|
"platforms": {
|
|
"buzz": {
|
|
"enabled": True,
|
|
"extra": {
|
|
"relay_url": "https://profile.relay",
|
|
"credentials_file": str(creds),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
multiplex_scope()
|
|
token = set_hermes_home_override(str(tmp_path))
|
|
try:
|
|
# The default profile's env relay+key must NOT pass the gate on
|
|
# their own for a profile without a buzz config...
|
|
assert check_requirements() is True # profile config passes
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
|
|
# A profile whose config.yaml has no buzz entry fails closed even
|
|
# though the default profile's env values are present.
|
|
empty_home = tmp_path / "empty-profile"
|
|
empty_home.mkdir()
|
|
multiplex_scope()
|
|
token = set_hermes_home_override(str(empty_home))
|
|
try:
|
|
assert check_requirements() is False
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
|
|
def test_env_enablement_scoped_returns_none(self, multiplex_scope, default_profile_env):
|
|
"""Scoped env enablement must not fabricate Buzz for a profile from
|
|
the default profile's env values."""
|
|
multiplex_scope()
|
|
assert _env_enablement() is None
|
|
|
|
def test_apply_yaml_config_scoped_skips_env_bridge(
|
|
self, multiplex_scope, default_profile_env, monkeypatch
|
|
):
|
|
"""A secondary profile's YAML values must not be pinned into the
|
|
process env for every other profile (first-writer-wins)."""
|
|
for var in ("BUZZ_RELAY_URL", "BUZZ_HOME_CHANNEL", "BUZZ_CHANNELS"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
multiplex_scope()
|
|
_buzz_mod._apply_yaml_config(
|
|
{},
|
|
{"extra": {"relay_url": "https://profile.relay", "home_channel": "pchan"}},
|
|
)
|
|
import os as _os
|
|
|
|
assert "BUZZ_RELAY_URL" not in _os.environ
|
|
assert "BUZZ_HOME_CHANNEL" not in _os.environ
|
|
assert "BUZZ_CHANNELS" not in _os.environ
|
|
|
|
def test_standalone_send_scoped_uses_profile_extra(
|
|
self, multiplex_scope, default_profile_env, monkeypatch, tmp_path
|
|
):
|
|
multiplex_scope()
|
|
from gateway.config import PlatformConfig
|
|
|
|
cli = tmp_path / "buzz"
|
|
cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
calls = {}
|
|
|
|
async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=None):
|
|
calls["relay"] = relay_url
|
|
return 0, '{"accepted": true, "event_id": "e1"}', ""
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
monkeypatch.setattr(
|
|
_buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1profile"
|
|
)
|
|
result = asyncio.run(
|
|
_standalone_send(
|
|
PlatformConfig(
|
|
enabled=True,
|
|
extra={"relay_url": "https://profile.relay", "cli_path": str(cli)},
|
|
),
|
|
"chan-x",
|
|
"hello",
|
|
)
|
|
)
|
|
assert result.get("success") is True
|
|
assert calls["relay"] == "https://profile.relay"
|
|
|
|
def test_secondary_partial_extra_fills_missing_keys_from_defaults(
|
|
self, multiplex_scope, default_profile_env
|
|
):
|
|
"""Partial config: configured keys win, unconfigured keys fall back to
|
|
their own defaults — never to the default profile's env values."""
|
|
from gateway.config import PlatformConfig
|
|
|
|
multiplex_scope()
|
|
adapter = BuzzAdapter(
|
|
PlatformConfig(enabled=True, extra={"relay_url": "https://profile.relay"})
|
|
)
|
|
assert adapter.relay_url == "https://profile.relay"
|
|
assert adapter.channels == []
|
|
assert adapter.home_channel == ""
|
|
assert adapter.poll_interval == _buzz_mod._DEFAULT_POLL_INTERVAL
|
|
assert adapter.transport == "auto"
|
|
assert adapter._allowed_pubkeys == set()
|
|
|
|
def test_ws_auth_tag_not_borrowed_from_default_profile_env(
|
|
self, multiplex_scope, default_profile_env
|
|
):
|
|
"""BUZZ_AUTH_TAG is per-identity NIP-OA owner attestation: a scoped
|
|
secondary profile without one must not sign its NIP-42 auth event
|
|
with the default profile's tag from os.environ (#98738)."""
|
|
import asyncio as _asyncio
|
|
|
|
multiplex_scope()
|
|
adapter = BuzzAdapter.__new__(BuzzAdapter)
|
|
adapter._private_key = "00" * 31 + "03"
|
|
adapter._websocket_url = lambda: "wss://relay.example"
|
|
|
|
class _FakeWS:
|
|
def __init__(self):
|
|
self.sent = []
|
|
|
|
async def recv(self):
|
|
if self.sent:
|
|
return json.dumps(["OK", self.sent[0][1]["id"], True, "ok"])
|
|
return json.dumps(["AUTH", "challenge-1"])
|
|
|
|
async def send(self, raw):
|
|
self.sent.append(json.loads(raw))
|
|
|
|
ws = _FakeWS()
|
|
_asyncio.run(adapter._authenticate_websocket(ws))
|
|
tags = [t for t in ws.sent[0][1]["tags"] if t and t[0] == "auth"]
|
|
assert tags == []
|
|
|
|
def test_ws_auth_tag_scoped_profile_uses_its_own_tag(
|
|
self, multiplex_scope
|
|
):
|
|
"""Positive control: a tag present in the profile's own secret scope
|
|
IS attached to the NIP-42 auth event."""
|
|
import asyncio as _asyncio
|
|
|
|
profile_tag = json.dumps(["auth", "p" * 64, "", "q" * 128])
|
|
multiplex_scope({"BUZZ_AUTH_TAG": profile_tag})
|
|
adapter = BuzzAdapter.__new__(BuzzAdapter)
|
|
adapter._private_key = "00" * 31 + "03"
|
|
adapter._websocket_url = lambda: "wss://relay.example"
|
|
|
|
class _FakeWS:
|
|
def __init__(self):
|
|
self.sent = []
|
|
|
|
async def recv(self):
|
|
if self.sent:
|
|
return json.dumps(["OK", self.sent[0][1]["id"], True, "ok"])
|
|
return json.dumps(["AUTH", "challenge-1"])
|
|
|
|
async def send(self, raw):
|
|
self.sent.append(json.loads(raw))
|
|
|
|
ws = _FakeWS()
|
|
_asyncio.run(adapter._authenticate_websocket(ws))
|
|
tags = [t for t in ws.sent[0][1]["tags"] if t and t[0] == "auth"]
|
|
assert tags == [json.loads(profile_tag)]
|
|
|
|
def test_ws_auth_tag_unscoped_default_profile_keeps_env(
|
|
self, default_profile_env
|
|
):
|
|
"""The default profile constructs unscoped even under multiplex, so
|
|
its env-provided auth tag still applies (legacy behavior kept)."""
|
|
import asyncio as _asyncio
|
|
|
|
from agent.secret_scope import set_multiplex_active
|
|
|
|
set_multiplex_active(True)
|
|
try:
|
|
adapter = BuzzAdapter.__new__(BuzzAdapter)
|
|
adapter._private_key = "00" * 31 + "03"
|
|
adapter._websocket_url = lambda: "wss://relay.example"
|
|
|
|
class _FakeWS:
|
|
def __init__(self):
|
|
self.sent = []
|
|
|
|
async def recv(self):
|
|
if self.sent:
|
|
return json.dumps(["OK", self.sent[0][1]["id"], True, "ok"])
|
|
return json.dumps(["AUTH", "challenge-1"])
|
|
|
|
async def send(self, raw):
|
|
self.sent.append(json.loads(raw))
|
|
|
|
ws = _FakeWS()
|
|
_asyncio.run(adapter._authenticate_websocket(ws))
|
|
finally:
|
|
set_multiplex_active(False)
|
|
tags = [t for t in ws.sent[0][1]["tags"] if t and t[0] == "auth"]
|
|
assert tags == [["auth", "default-profile-tag", "", "x"]]
|
|
|
|
def test_validate_config_scoped_extra_is_authoritative(
|
|
self, multiplex_scope, default_profile_env, tmp_path
|
|
):
|
|
"""Scoped validation reads the profile's extra, not the default
|
|
profile's env relay/key: unconfigured fails closed, configured
|
|
passes via its own credentials file."""
|
|
creds = tmp_path / "creds.json"
|
|
creds.write_text(json.dumps({"nsec": "nsec1profile"}), encoding="utf-8")
|
|
from gateway.config import PlatformConfig
|
|
|
|
multiplex_scope()
|
|
assert validate_config(PlatformConfig(enabled=True, extra={})) is False
|
|
assert (
|
|
validate_config(
|
|
PlatformConfig(
|
|
enabled=True,
|
|
extra={
|
|
"relay_url": "https://profile.relay",
|
|
"credentials_file": str(creds),
|
|
},
|
|
)
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_validate_config_unscoped_keeps_env_precedence(
|
|
self, default_profile_env
|
|
):
|
|
"""Single-profile/unscoped: env relay + env key still validate even
|
|
with an empty extra mapping."""
|
|
from gateway.config import PlatformConfig
|
|
|
|
assert validate_config(PlatformConfig(enabled=True, extra={})) is True
|
|
|
|
def test_standalone_send_scoped_target_falls_back_to_profile_home(
|
|
self, multiplex_scope, default_profile_env, monkeypatch, tmp_path
|
|
):
|
|
"""With no explicit chat_id, the scoped standalone send targets the
|
|
profile's own home_channel — never the default profile's env one."""
|
|
multiplex_scope()
|
|
from gateway.config import PlatformConfig
|
|
|
|
cli = tmp_path / "buzz"
|
|
cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
calls = {}
|
|
|
|
async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=None):
|
|
calls["args"] = args
|
|
return 0, '{"accepted": true, "event_id": "e1"}', ""
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
monkeypatch.setattr(
|
|
_buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1profile"
|
|
)
|
|
result = asyncio.run(
|
|
_standalone_send(
|
|
PlatformConfig(
|
|
enabled=True,
|
|
extra={
|
|
"relay_url": "https://profile.relay",
|
|
"cli_path": str(cli),
|
|
"home_channel": "pchan",
|
|
},
|
|
),
|
|
"",
|
|
"hello",
|
|
)
|
|
)
|
|
assert result.get("success") is True
|
|
assert calls["args"][calls["args"].index("--channel") + 1] == "pchan"
|
|
|
|
def test_standalone_send_scoped_without_target_fails_closed(
|
|
self, multiplex_scope, default_profile_env, monkeypatch, tmp_path
|
|
):
|
|
"""No chat_id and no profile home_channel: the error is returned —
|
|
the default profile's env BUZZ_HOME_CHANNEL must not be borrowed."""
|
|
multiplex_scope()
|
|
from gateway.config import PlatformConfig
|
|
|
|
cli = tmp_path / "buzz"
|
|
cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
|
|
async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=None):
|
|
raise AssertionError("CLI must not run without a resolved target")
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
monkeypatch.setattr(
|
|
_buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1profile"
|
|
)
|
|
result = asyncio.run(
|
|
_standalone_send(
|
|
PlatformConfig(
|
|
enabled=True,
|
|
extra={"relay_url": "https://profile.relay", "cli_path": str(cli)},
|
|
),
|
|
"",
|
|
"hello",
|
|
)
|
|
)
|
|
assert result == {
|
|
"error": "Buzz standalone send: no target channel (set BUZZ_HOME_CHANNEL)"
|
|
}
|
|
|
|
|
|
# ── CLI error contract ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCliErrorContract:
|
|
|
|
def test_parses_json_error(self):
|
|
msg = _cli_error_message('{"error":"relay_error","message":"boom","retryable":false}', 2)
|
|
assert "relay_error" in msg and "boom" in msg and "exit 2" in msg
|
|
|
|
@pytest.mark.parametrize(
|
|
"stderr",
|
|
[
|
|
"x" * 100_000,
|
|
json.dumps({"error": "relay_error", "message": "x" * 100_000}),
|
|
],
|
|
)
|
|
def test_bounds_untrusted_cli_error_output(self, stderr):
|
|
msg = _cli_error_message(stderr, 2)
|
|
assert len(msg) <= 900
|
|
assert msg.endswith("...")
|
|
|
|
|
|
# ── Seeding / high-water mark / de-dupe ───────────────────────────────────
|
|
|
|
|
|
class TestPollingDedupe:
|
|
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = _make_adapter()
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seed_sets_high_water_mark_without_dispatch(self, adapter):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [
|
|
_event("e1", content="@Chip old history", created_at=100),
|
|
_event("e2", content="@Chip newer history", created_at=200),
|
|
])
|
|
adapter._run_cli = cli
|
|
await adapter._seed_channel(CHANNEL, chat_type="group")
|
|
|
|
state = adapter._channel_state[CHANNEL]
|
|
assert state["last_ts"] == 200
|
|
assert set(state["seen"]) == {"e1", "e2"}
|
|
# Seeding must never replay history into the agent
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_event_dispatched_once(self, adapter):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [_event("e1", content="@Chip hi", created_at=100)])
|
|
adapter._run_cli = cli
|
|
await adapter._seed_channel(CHANNEL, chat_type="group")
|
|
|
|
# Poll 1: seeded event + a genuinely new mention
|
|
cli.responses.clear()
|
|
cli.script("messages", "get", [
|
|
_event("e1", content="@Chip hi", created_at=100),
|
|
_event("e2", content="hey @Chip, ping", created_at=150),
|
|
])
|
|
await adapter._poll_channel(CHANNEL)
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["e2"]
|
|
assert adapter._dispatched[0]["text"] == "hey @Chip, ping"
|
|
assert adapter._channel_state[CHANNEL]["last_ts"] == 150
|
|
|
|
# Poll 2: identical response — the seen-id set must de-dupe
|
|
await adapter._poll_channel(CHANNEL)
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_malformed_attachment_url_does_not_abort_following_event(self, adapter):
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
malformed = _event("malformed-attachment", content="background chatter", created_at=159)
|
|
malformed["tags"].append([
|
|
"imeta",
|
|
"url https://[invalid/media.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 1",
|
|
"filename invalid.bin",
|
|
])
|
|
valid = _event("following-valid", content="@Chip still there?", created_at=160)
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [malformed, valid])
|
|
adapter._run_cli = cli
|
|
|
|
await adapter._poll_channel(CHANNEL)
|
|
|
|
assert [item["message_id"] for item in adapter._dispatched] == ["following-valid"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_addressed_attachment_is_cached_and_dispatched_to_agent(self, adapter):
|
|
attachment = CachedMedia(
|
|
path="/agent/cache/doc_handoff.txt",
|
|
media_type="text/plain",
|
|
kind="document",
|
|
display_name="handoff.txt",
|
|
)
|
|
adapter._download_attachment = AsyncMock(return_value=attachment)
|
|
adapter._user_names[OTHER_PUBKEY] = "Other"
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
event = _event("attachment-event", content="@Chip inspect this", created_at=160)
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/abc.bin",
|
|
"m text/plain",
|
|
"x " + "a" * 64,
|
|
"size 12",
|
|
"filename handoff.txt",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
adapter._download_attachment.assert_awaited_once()
|
|
dispatched = adapter._dispatched[-1]
|
|
assert dispatched["media_urls"] == [attachment.path]
|
|
assert dispatched["media_types"] == ["text/plain"]
|
|
assert dispatched["message_type"] is MessageType.DOCUMENT
|
|
assert attachment.context_note() not in dispatched["text"]
|
|
assert dispatched["raw_message"] is event
|
|
|
|
|
|
class TestInboundAttachments:
|
|
|
|
def test_imeta_total_declared_bytes_are_bounded(self):
|
|
event = _event("bounded", content="@Chip files")
|
|
per_file_size = 6 * 1024 * 1024
|
|
for index in range(4):
|
|
event["tags"].append([
|
|
"imeta",
|
|
f"url https://test.relay/media/{index}.bin",
|
|
"m application/octet-stream",
|
|
"x " + format(index + 1, "064x"),
|
|
f"size {per_file_size}",
|
|
f"filename {index}.bin",
|
|
])
|
|
|
|
attachments = BuzzAdapter._imeta_attachments(event)
|
|
|
|
assert len(attachments) == 3
|
|
assert sum(item["size"] for item in attachments) <= 20 * 1024 * 1024
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_caches_only_exact_size_and_sha256(self, monkeypatch):
|
|
import httpx
|
|
|
|
payload = b"verified Buzz attachment"
|
|
digest = hashlib.sha256(payload).hexdigest()
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def handler(request):
|
|
assert request.url.host == "test.relay"
|
|
return httpx.Response(
|
|
200,
|
|
content=payload,
|
|
headers={"content-length": str(len(payload))},
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
httpx,
|
|
"AsyncClient",
|
|
lambda **kwargs: real_async_client(
|
|
transport=httpx.MockTransport(handler),
|
|
**kwargs,
|
|
),
|
|
)
|
|
adapter = _make_adapter()
|
|
|
|
cached = await adapter._download_attachment({
|
|
"url": "https://test.relay/media/verified.bin",
|
|
"sha256": digest,
|
|
"size": len(payload),
|
|
"filename": "verified.txt",
|
|
"mime_type": "text/plain",
|
|
})
|
|
|
|
assert cached is not None
|
|
assert cached.kind == "document"
|
|
assert cached.media_type == "text/plain"
|
|
assert Path(cached.path).read_bytes() == payload
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_rejects_untrusted_attachment_host_before_network(self, monkeypatch):
|
|
import httpx
|
|
|
|
def must_not_create_client(**kwargs):
|
|
raise AssertionError("network client must not be created")
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", must_not_create_client)
|
|
adapter = _make_adapter()
|
|
|
|
cached = await adapter._download_attachment({
|
|
"url": "https://untrusted.example/media/file.bin",
|
|
"sha256": "a" * 64,
|
|
"size": 12,
|
|
"filename": "file.bin",
|
|
"mime_type": "application/octet-stream",
|
|
})
|
|
|
|
assert cached is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_rejects_unconfigured_nondefault_port_before_network(self, monkeypatch):
|
|
import httpx
|
|
|
|
def must_not_create_client(**kwargs):
|
|
raise AssertionError("network client must not be created")
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", must_not_create_client)
|
|
adapter = _make_adapter()
|
|
|
|
cached = await adapter._download_attachment({
|
|
"url": "https://test.relay:8443/media/file.bin",
|
|
"sha256": "a" * 64,
|
|
"size": 12,
|
|
"filename": "file.bin",
|
|
"mime_type": "application/octet-stream",
|
|
})
|
|
|
|
assert cached is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_allows_explicitly_configured_nondefault_port(self, monkeypatch):
|
|
import httpx
|
|
|
|
payload = b"x"
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def handler(request):
|
|
assert request.url.port == 8443
|
|
return httpx.Response(200, content=payload, headers={"content-length": "1"})
|
|
|
|
monkeypatch.setattr(
|
|
httpx,
|
|
"AsyncClient",
|
|
lambda **kwargs: real_async_client(
|
|
transport=httpx.MockTransport(handler),
|
|
**kwargs,
|
|
),
|
|
)
|
|
adapter = _make_adapter({"attachment_hosts": ["test.relay:8443"]})
|
|
|
|
cached = await adapter._download_attachment({
|
|
"url": "https://test.relay:8443/media/file.bin",
|
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
"size": 1,
|
|
"filename": "file.bin",
|
|
"mime_type": "application/octet-stream",
|
|
})
|
|
|
|
assert cached is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unaddressed_channel_attachment_is_not_downloaded(self):
|
|
adapter = _make_adapter()
|
|
authorization_check = MagicMock(return_value=True)
|
|
adapter.set_authorization_check(authorization_check)
|
|
adapter._cache_inbound_attachments = AsyncMock()
|
|
adapter._download_attachment = AsyncMock()
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
event = _event("unaddressed-attachment", content="shared file")
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/file.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 12",
|
|
"filename file.bin",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
authorization_check.assert_not_called()
|
|
adapter._cache_inbound_attachments.assert_not_awaited()
|
|
adapter._download_attachment.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_attachment_download_is_visible_to_agent(self):
|
|
adapter = _make_adapter()
|
|
adapter._download_attachment = AsyncMock(return_value=None)
|
|
adapter._user_names[OTHER_PUBKEY] = "Other"
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
dispatched.append(kwargs)
|
|
|
|
adapter._dispatch_message = capture
|
|
event = _event("failed-attachment", content="@Chip inspect this")
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/file.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 12",
|
|
"filename file.bin",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
assert "could not be downloaded" in dispatched[-1]["text"]
|
|
assert dispatched[-1]["media_urls"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_builds_document_message_event_with_cached_path(self):
|
|
adapter = _make_adapter()
|
|
adapter._message_handler = AsyncMock()
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
await adapter._dispatch_message(
|
|
text="inspect",
|
|
chat_id=CHANNEL,
|
|
chat_type="group",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Other",
|
|
message_id="document-event",
|
|
created_at=1000,
|
|
media_urls=["/agent/cache/doc_report.pdf"],
|
|
media_types=["application/pdf"],
|
|
message_type=MessageType.DOCUMENT,
|
|
raw_message={"id": "document-event"},
|
|
)
|
|
|
|
call = adapter.handle_message.await_args
|
|
assert call is not None
|
|
dispatched_event = call.args[0]
|
|
assert dispatched_event.message_type is MessageType.DOCUMENT
|
|
assert dispatched_event.media_urls == ["/agent/cache/doc_report.pdf"]
|
|
assert dispatched_event.media_types == ["application/pdf"]
|
|
assert dispatched_event.media_text_inlined == [False]
|
|
assert dispatched_event.raw_message == {"id": "document-event"}
|
|
|
|
def test_imeta_sanitizes_filename_and_rejects_incomplete_metadata(self):
|
|
event = _event("metadata", content="@Chip files")
|
|
event["tags"].extend([
|
|
[
|
|
"imeta",
|
|
"url https://test.relay/media/valid.bin",
|
|
"m text/plain",
|
|
"x " + "a" * 64,
|
|
"size 12",
|
|
"filename ../../private/report.txt",
|
|
],
|
|
[
|
|
"imeta",
|
|
"url http://test.relay/media/insecure.bin",
|
|
"x " + "b" * 64,
|
|
"size 12",
|
|
"filename insecure.bin",
|
|
],
|
|
[
|
|
"imeta",
|
|
"url https://test.relay/media/no-hash.bin",
|
|
"size 12",
|
|
"filename no-hash.bin",
|
|
],
|
|
])
|
|
|
|
attachments = BuzzAdapter._imeta_attachments(event)
|
|
|
|
assert len(attachments) == 1
|
|
assert attachments[0]["filename"] == "report.txt"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attachment_only_dm_is_downloaded_and_dispatched(self):
|
|
adapter = _make_adapter()
|
|
attachment = CachedMedia(
|
|
path="/agent/cache/doc_attachment.bin",
|
|
media_type="application/octet-stream",
|
|
kind="document",
|
|
display_name="attachment.bin",
|
|
)
|
|
adapter._download_attachment = AsyncMock(return_value=attachment)
|
|
adapter._user_names[OTHER_PUBKEY] = "Other"
|
|
adapter._channel_state[DM_CHANNEL] = {
|
|
"chat_type": "dm",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
dispatched.append(kwargs)
|
|
|
|
adapter._dispatch_message = capture
|
|
event = _event("attachment-only", content="")
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/file.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 12",
|
|
"filename attachment.bin",
|
|
])
|
|
|
|
await adapter._handle_event(
|
|
DM_CHANNEL,
|
|
adapter._channel_state[DM_CHANNEL],
|
|
event,
|
|
)
|
|
|
|
adapter._download_attachment.assert_awaited_once()
|
|
assert dispatched[-1]["media_urls"] == [attachment.path]
|
|
assert dispatched[-1]["message_type"] is MessageType.DOCUMENT
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_malformed_attachment_only_dm_dispatches_once_with_bounded_note(self):
|
|
adapter = _make_adapter()
|
|
adapter._download_attachment = AsyncMock()
|
|
adapter._user_names[OTHER_PUBKEY] = "Other"
|
|
adapter._channel_state[DM_CHANNEL] = {
|
|
"chat_type": "dm",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
adapter._dispatch_message = AsyncMock()
|
|
event = _event("malformed-attachment-only", content="")
|
|
event["tags"].append(["imeta", "url definitely-not-a-url"])
|
|
|
|
await adapter._handle_event(DM_CHANNEL, adapter._channel_state[DM_CHANNEL], event)
|
|
await adapter._handle_event(DM_CHANNEL, adapter._channel_state[DM_CHANNEL], event)
|
|
|
|
adapter._download_attachment.assert_not_awaited()
|
|
adapter._dispatch_message.assert_awaited_once()
|
|
call = adapter._dispatch_message.await_args
|
|
assert call is not None
|
|
dispatched = call.kwargs
|
|
assert "1 Buzz attachment(s) rejected" in dispatched["text"]
|
|
assert len(dispatched["text"]) <= 80
|
|
assert dispatched["media_urls"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_excess_imeta_is_reported_while_accepted_files_remain_attached(self):
|
|
adapter = _make_adapter()
|
|
adapter._user_names[OTHER_PUBKEY] = "Other"
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
adapter._download_attachment = AsyncMock(
|
|
side_effect=lambda metadata: CachedMedia(
|
|
f"/cache/{metadata['filename']}",
|
|
metadata["mime_type"],
|
|
"document",
|
|
metadata["filename"],
|
|
)
|
|
)
|
|
adapter._dispatch_message = AsyncMock()
|
|
event = _event("excess-imeta", content="@Chip inspect")
|
|
for index in range(5):
|
|
event["tags"].append([
|
|
"imeta",
|
|
f"url https://test.relay/media/{index}.bin",
|
|
"m application/octet-stream",
|
|
"x " + format(index + 1, "064x"),
|
|
"size 1",
|
|
f"filename {index}.bin",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
assert adapter._download_attachment.await_count == 4
|
|
call = adapter._dispatch_message.await_args
|
|
assert call is not None
|
|
dispatched = call.kwargs
|
|
assert "1 Buzz attachment(s) rejected" in dispatched["text"]
|
|
assert len(dispatched["media_urls"]) == 4
|
|
|
|
def test_imeta_bounds_filename_to_filesystem_safe_utf8_length(self):
|
|
event = _event("long-name", content="@Chip file")
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/file.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 1",
|
|
"filename " + ("é" * 180) + ".pdf",
|
|
])
|
|
|
|
filename = BuzzAdapter._imeta_attachments(event)[0]["filename"]
|
|
|
|
assert len(filename.encode("utf-8")) <= 120
|
|
assert filename.endswith(".pdf")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_write_failure_is_treated_as_failed_attachment(self, monkeypatch):
|
|
import httpx
|
|
|
|
payload = b"x"
|
|
digest = hashlib.sha256(payload).hexdigest()
|
|
real_async_client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
httpx,
|
|
"AsyncClient",
|
|
lambda **kwargs: real_async_client(
|
|
transport=httpx.MockTransport(
|
|
lambda _request: httpx.Response(
|
|
200,
|
|
content=payload,
|
|
headers={"content-length": "1"},
|
|
)
|
|
),
|
|
**kwargs,
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
_buzz_mod,
|
|
"cache_media_bytes_async",
|
|
AsyncMock(side_effect=OSError(36, "File name too long")),
|
|
)
|
|
adapter = _make_adapter()
|
|
|
|
cached = await adapter._download_attachment({
|
|
"url": "https://test.relay/media/file.bin",
|
|
"sha256": digest,
|
|
"size": 1,
|
|
"filename": "file.bin",
|
|
"mime_type": "application/octet-stream",
|
|
})
|
|
|
|
assert cached is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_has_total_deadline(self, monkeypatch):
|
|
import httpx
|
|
|
|
payload = b"x"
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
async def slow_handler(_request):
|
|
await asyncio.sleep(0.05)
|
|
return httpx.Response(200, content=payload, headers={"content-length": "1"})
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_ATTACHMENT_DOWNLOAD_TIMEOUT", 0.01)
|
|
monkeypatch.setattr(
|
|
httpx,
|
|
"AsyncClient",
|
|
lambda **kwargs: real_async_client(
|
|
transport=httpx.MockTransport(slow_handler),
|
|
**kwargs,
|
|
),
|
|
)
|
|
adapter = _make_adapter()
|
|
|
|
cached = await adapter._download_attachment({
|
|
"url": "https://test.relay/media/file.bin",
|
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
"size": 1,
|
|
"filename": "file.bin",
|
|
"mime_type": "application/octet-stream",
|
|
})
|
|
|
|
assert cached is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_mixed_attachments_use_document_semantics(self):
|
|
adapter = _make_adapter()
|
|
adapter._user_names[OTHER_PUBKEY] = "Other"
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
cached = [
|
|
CachedMedia("/cache/image.png", "image/png", "image", "image.png"),
|
|
CachedMedia("/cache/audio.mp3", "audio/mpeg", "audio", "audio.mp3"),
|
|
]
|
|
adapter._cache_inbound_attachments = AsyncMock(return_value=cached)
|
|
dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
dispatched.append(kwargs)
|
|
|
|
adapter._dispatch_message = capture
|
|
event = _event("mixed", content="@Chip inspect")
|
|
for index, mime_type in enumerate(("image/png", "audio/mpeg")):
|
|
event["tags"].append([
|
|
"imeta",
|
|
f"url https://test.relay/media/{index}.bin",
|
|
f"m {mime_type}",
|
|
"x " + format(index + 1, "064x"),
|
|
"size 1",
|
|
f"filename {index}.bin",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
assert dispatched[-1]["message_type"] is MessageType.DOCUMENT
|
|
assert dispatched[-1]["media_types"] == ["image/png", "audio/mpeg"]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("status", "payload", "headers", "declared_size", "expected_digest"),
|
|
[
|
|
(302, b"", {}, 1, "a" * 64),
|
|
(200, b"x", {"content-length": "invalid"}, 1, hashlib.sha256(b"x").hexdigest()),
|
|
(200, b"x", {}, 2, hashlib.sha256(b"x").hexdigest()),
|
|
(200, b"xx", {}, 1, hashlib.sha256(b"xx").hexdigest()),
|
|
(200, b"x", {}, 1, "a" * 64),
|
|
],
|
|
)
|
|
async def test_download_rejects_invalid_response_or_integrity(
|
|
self,
|
|
monkeypatch,
|
|
status,
|
|
payload,
|
|
headers,
|
|
declared_size,
|
|
expected_digest,
|
|
):
|
|
import httpx
|
|
|
|
real_async_client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
httpx,
|
|
"AsyncClient",
|
|
lambda **kwargs: real_async_client(
|
|
transport=httpx.MockTransport(
|
|
lambda _request: httpx.Response(status, content=payload, headers=headers)
|
|
),
|
|
**kwargs,
|
|
),
|
|
)
|
|
adapter = _make_adapter()
|
|
|
|
cached = await adapter._download_attachment({
|
|
"url": "https://test.relay/media/file.bin",
|
|
"sha256": expected_digest,
|
|
"size": declared_size,
|
|
"filename": "file.bin",
|
|
"mime_type": "application/octet-stream",
|
|
})
|
|
|
|
assert cached is None
|
|
|
|
def test_imeta_rejects_url_credentials_and_fragments_and_caps_items(self):
|
|
event = _event("url-safety", content="@Chip files")
|
|
event["tags"].extend([
|
|
[
|
|
"imeta",
|
|
"url https://user:password@test.relay/media/private.bin",
|
|
"x " + "a" * 64,
|
|
"size 1",
|
|
"filename private.bin",
|
|
],
|
|
[
|
|
"imeta",
|
|
"url https://test.relay/media/fragment.bin#hidden",
|
|
"x " + "b" * 64,
|
|
"size 1",
|
|
"filename fragment.bin",
|
|
],
|
|
])
|
|
for index in range(6):
|
|
event["tags"].append([
|
|
"imeta",
|
|
f"url https://test.relay/media/{index}.bin",
|
|
"x " + format(index + 1, "064x"),
|
|
"size 1",
|
|
f"filename {index}.bin",
|
|
])
|
|
|
|
attachments = BuzzAdapter._imeta_attachments(event)
|
|
|
|
assert len(attachments) == 4
|
|
assert all("@" not in item["url"] and "#" not in item["url"] for item in attachments)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_self_attachment_stops_before_authorization_or_cache(self):
|
|
adapter = _make_adapter()
|
|
authorization_check = MagicMock(return_value=True)
|
|
adapter.set_authorization_check(authorization_check)
|
|
adapter._cache_inbound_attachments = AsyncMock()
|
|
adapter._dispatch_message = AsyncMock()
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
event = _event("self-attachment", pubkey=SELF_PUBKEY, content="@Chip inspect")
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/file.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 1",
|
|
"filename file.bin",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
authorization_check.assert_not_called()
|
|
adapter._cache_inbound_attachments.assert_not_awaited()
|
|
adapter._dispatch_message.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthorized_sender_attachment_is_not_downloaded(self):
|
|
adapter = _make_adapter()
|
|
adapter._allowed_pubkeys = {"f" * 64}
|
|
authorization_check = MagicMock(return_value=True)
|
|
adapter.set_authorization_check(authorization_check)
|
|
adapter._cache_inbound_attachments = AsyncMock()
|
|
adapter._download_attachment = AsyncMock()
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
event = _event("unauthorized-attachment", content="@Chip inspect")
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/file.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 1",
|
|
"filename file.bin",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
authorization_check.assert_not_called()
|
|
adapter._cache_inbound_attachments.assert_not_awaited()
|
|
adapter._download_attachment.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("authorization", [False, None, "raise", "truthy"])
|
|
async def test_non_true_gateway_authority_never_caches_even_for_locally_allowed_sender(
|
|
self,
|
|
authorization,
|
|
):
|
|
adapter = _make_adapter()
|
|
adapter._run_cli = AsyncMock(side_effect=AssertionError("authorization test invoked Buzz CLI"))
|
|
adapter._resolve_user_name = AsyncMock(return_value="Other")
|
|
adapter._allowed_pubkeys = {OTHER_PUBKEY}
|
|
if authorization is None:
|
|
adapter.set_authorization_check(None)
|
|
elif authorization == "raise":
|
|
def raise_unknown(*_args):
|
|
raise RuntimeError("authorization backend unavailable")
|
|
|
|
adapter.set_authorization_check(raise_unknown)
|
|
elif authorization == "truthy":
|
|
adapter.set_authorization_check(lambda *_args: "AUTHORIZED")
|
|
else:
|
|
adapter.set_authorization_check(lambda *_args: False)
|
|
adapter._cache_inbound_attachments = AsyncMock()
|
|
adapter._dispatch_message = AsyncMock()
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
event = _event(f"gateway-{authorization}-attachment", content="@Chip inspect")
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/file.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 1",
|
|
"filename file.bin",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
adapter._cache_inbound_attachments.assert_not_awaited()
|
|
adapter._dispatch_message.assert_awaited_once()
|
|
call = adapter._dispatch_message.await_args
|
|
assert call is not None
|
|
assert call.kwargs["media_urls"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_true_gateway_authority_caches_attachment(self):
|
|
adapter = _make_adapter()
|
|
adapter._run_cli = AsyncMock(side_effect=AssertionError("authorization test invoked Buzz CLI"))
|
|
adapter._resolve_user_name = AsyncMock(return_value="Other")
|
|
authorization_check = MagicMock(return_value=True)
|
|
adapter.set_authorization_check(authorization_check)
|
|
cached = CachedMedia(
|
|
"/cache/authorized.bin",
|
|
"application/octet-stream",
|
|
"document",
|
|
"authorized.bin",
|
|
)
|
|
adapter._cache_inbound_attachments = AsyncMock(return_value=[cached])
|
|
adapter._dispatch_message = AsyncMock()
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
event = _event("gateway-authorized-attachment", content="@Chip inspect")
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/file.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 1",
|
|
"filename file.bin",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
authorization_check.assert_called_once_with(OTHER_PUBKEY, "group", CHANNEL)
|
|
adapter._cache_inbound_attachments.assert_awaited_once()
|
|
call = adapter._dispatch_message.await_args
|
|
assert call is not None
|
|
assert call.kwargs["media_urls"] == [cached.path]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_gateway_auth_callback_defaults_to_no_attachment_side_effects(
|
|
self,
|
|
monkeypatch,
|
|
):
|
|
from gateway.config import GatewayConfig
|
|
from gateway.run import GatewayRunner
|
|
|
|
monkeypatch.delenv("GATEWAY_ALLOWED_USERS", raising=False)
|
|
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.config = GatewayConfig()
|
|
runner.adapters = {}
|
|
runner.pairing_store = MagicMock()
|
|
runner.pairing_store.is_approved.return_value = False
|
|
|
|
adapter = _make_adapter()
|
|
adapter._run_cli = AsyncMock(side_effect=AssertionError("authorization test invoked Buzz CLI"))
|
|
adapter._resolve_user_name = AsyncMock(return_value="Other")
|
|
adapter._allowed_pubkeys = {OTHER_PUBKEY}
|
|
adapter.set_authorization_check(runner._make_adapter_auth_check(adapter.platform))
|
|
adapter._cache_inbound_attachments = AsyncMock()
|
|
adapter._dispatch_message = AsyncMock()
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
event = _event("gateway-default-denied-attachment", content="@Chip inspect")
|
|
event["tags"].append([
|
|
"imeta",
|
|
"url https://test.relay/media/file.bin",
|
|
"m application/octet-stream",
|
|
"x " + "a" * 64,
|
|
"size 1",
|
|
"filename file.bin",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
runner.pairing_store.is_approved.assert_called_once_with("buzz", OTHER_PUBKEY)
|
|
adapter._cache_inbound_attachments.assert_not_awaited()
|
|
adapter._dispatch_message.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("kind", "mime_type", "expected_type"),
|
|
[
|
|
("image", "image/png", MessageType.PHOTO),
|
|
("video", "video/mp4", MessageType.VIDEO),
|
|
("audio", "audio/mpeg", MessageType.AUDIO),
|
|
("document", "application/pdf", MessageType.DOCUMENT),
|
|
],
|
|
)
|
|
async def test_homogeneous_attachment_kind_sets_message_type(
|
|
self,
|
|
kind,
|
|
mime_type,
|
|
expected_type,
|
|
):
|
|
adapter = _make_adapter()
|
|
adapter._user_names[OTHER_PUBKEY] = "Other"
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
cached = CachedMedia(
|
|
f"/cache/file-{kind}",
|
|
mime_type,
|
|
kind,
|
|
f"file-{kind}",
|
|
)
|
|
adapter._cache_inbound_attachments = AsyncMock(return_value=[cached])
|
|
dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
dispatched.append(kwargs)
|
|
|
|
adapter._dispatch_message = capture
|
|
event = _event(f"homogeneous-{kind}", content="@Chip inspect")
|
|
event["tags"].append([
|
|
"imeta",
|
|
f"url https://test.relay/media/{kind}",
|
|
f"m {mime_type}",
|
|
"x " + "a" * 64,
|
|
"size 1",
|
|
f"filename file-{kind}",
|
|
])
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
assert dispatched[-1]["message_type"] is expected_type
|
|
|
|
|
|
# ── Mention gating / DMs / authorization ──────────────────────────────────
|
|
|
|
|
|
class TestMentionGating:
|
|
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = _make_adapter()
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
a._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
return a
|
|
|
|
async def _poll_with(self, adapter, *events):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", list(events))
|
|
adapter._run_cli = cli
|
|
await adapter._poll_channel(CHANNEL)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unaddressed_channel_message_ignored(self, adapter):
|
|
await self._poll_with(adapter, _event("e1", content="just chatting", created_at=10))
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_name_mention_dispatched(self, adapter):
|
|
await self._poll_with(adapter, _event("e1", content="hey @Chip can you help?", created_at=10))
|
|
assert len(adapter._dispatched) == 1
|
|
assert adapter._dispatched[0]["text"] == "hey @Chip can you help?"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
[
|
|
"Chip should stay silent",
|
|
"ask @Chipmunk instead",
|
|
"ask @Chip-bot instead",
|
|
"email chip@example.com",
|
|
],
|
|
)
|
|
async def test_bare_or_prefix_name_does_not_dispatch(self, adapter, content):
|
|
await self._poll_with(adapter, _event("e1", content=content, created_at=10))
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("identity", [SELF_NPUB, SELF_PUBKEY])
|
|
async def test_identity_text_dispatches(self, adapter, identity):
|
|
await self._poll_with(
|
|
adapter,
|
|
_event("e1", content=f"please check {identity}", created_at=10),
|
|
)
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
[f"a{SELF_PUBKEY}b", f"x{SELF_NPUB}y"],
|
|
)
|
|
async def test_identity_substring_does_not_dispatch(self, adapter, content):
|
|
await self._poll_with(
|
|
adapter,
|
|
_event("e1", content=content, created_at=10),
|
|
)
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_signed_recipient_tag_dispatches_without_text_mention(self, adapter):
|
|
event = _event("e1", content="please take a look", created_at=10)
|
|
event["tags"].append(["p", SELF_PUBKEY])
|
|
await self._poll_with(adapter, event)
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_other_recipient_tag_does_not_dispatch(self, adapter):
|
|
event = _event("e1", content="please take a look", created_at=10)
|
|
event["tags"].append(["p", "b" * 64])
|
|
await self._poll_with(adapter, event)
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_require_mention_false_still_dispatches_unaddressed_message(self, adapter):
|
|
adapter.require_mention = False
|
|
await self._poll_with(adapter, _event("e1", content="just chatting", created_at=10))
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
def test_strip_mention_requires_at_for_display_name(self, adapter):
|
|
assert adapter._strip_mention("@Chip: /whoami") == "/whoami"
|
|
assert adapter._strip_mention("Chip: please review") == "Chip: please review"
|
|
assert adapter._strip_mention("@Chip-bot: please review") == "@Chip-bot: please review"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allowlist_blocks_unauthorized(self, adapter):
|
|
adapter._allowed_pubkeys = {"b" * 64}
|
|
await self._poll_with(adapter, _event("e1", content="@Chip hello", created_at=10))
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_agent_tag_reacts_without_dispatch(self, adapter):
|
|
adapter._allowed_pubkeys = {OTHER_PUBKEY}
|
|
adapter._reaction_only_pubkeys = {AGENT_PUBKEY}
|
|
adapter.send_reaction = AsyncMock(return_value=True)
|
|
event = _event("e1", pubkey=AGENT_PUBKEY, content="@Chip coordinate", created_at=10)
|
|
event["tags"].append(["p", SELF_PUBKEY])
|
|
|
|
await self._poll_with(adapter, event)
|
|
|
|
adapter.send_reaction.assert_awaited_once_with(CHANNEL, "e1", "👀")
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allowlist_takes_precedence_over_reaction_only(self, adapter):
|
|
adapter._allowed_pubkeys = {AGENT_PUBKEY}
|
|
adapter._reaction_only_pubkeys = {AGENT_PUBKEY}
|
|
adapter.send_reaction = AsyncMock(return_value=True)
|
|
event = _event("e1", pubkey=AGENT_PUBKEY, content="@Chip coordinate", created_at=10)
|
|
event["tags"].append(["p", SELF_PUBKEY])
|
|
|
|
await self._poll_with(adapter, event)
|
|
|
|
adapter.send_reaction.assert_not_awaited()
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_message_without_explicit_recipient_gets_no_reaction(self, adapter):
|
|
adapter._allowed_pubkeys = {OTHER_PUBKEY}
|
|
adapter._reaction_only_pubkeys = {AGENT_PUBKEY}
|
|
adapter.send_reaction = AsyncMock(return_value=True)
|
|
|
|
await self._poll_with(
|
|
adapter,
|
|
_event("e1", pubkey=AGENT_PUBKEY, content="@Chip coordinate", created_at=10),
|
|
)
|
|
|
|
adapter.send_reaction.assert_not_awaited()
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_sender_tag_gets_no_reaction(self, adapter):
|
|
adapter._allowed_pubkeys = {OTHER_PUBKEY}
|
|
adapter._reaction_only_pubkeys = {AGENT_PUBKEY}
|
|
adapter.send_reaction = AsyncMock(return_value=True)
|
|
event = _event("e1", pubkey="c" * 64, content="@Chip coordinate", created_at=10)
|
|
event["tags"].append(["p", SELF_PUBKEY])
|
|
|
|
await self._poll_with(adapter, event)
|
|
|
|
adapter.send_reaction.assert_not_awaited()
|
|
assert adapter._dispatched == []
|
|
|
|
|
|
# ── NIP-10 thread replies as addressed (issue #75826) ────────────────────
|
|
#
|
|
# With require_mention (default), channel replies whose direct parent is the
|
|
# agent's own message must dispatch even when the text has no @name — Buzz
|
|
# Desktop's natural reply affordance for /approve never types a mention.
|
|
|
|
|
|
def _tagged_event(event_id, channel, *, content, pubkey=OTHER_PUBKEY,
|
|
created_at=1000, kind=9, p=None, reply_to=None, root=None):
|
|
"""Event with the tag shapes observed on a live relay (h/p/e tags)."""
|
|
tags = [["h", channel]]
|
|
# NIP-10 order as Desktop emits: root first, then reply (when both set).
|
|
if root:
|
|
tags.append(["e", root, "", "root"])
|
|
if reply_to:
|
|
tags.append(["e", reply_to, "", "reply"])
|
|
if p:
|
|
tags.append(["p", p])
|
|
return {
|
|
"id": event_id,
|
|
"pubkey": pubkey,
|
|
"content": content,
|
|
"created_at": created_at,
|
|
"kind": kind,
|
|
"tags": tags,
|
|
}
|
|
|
|
|
|
class TestNip10ThreadReplyMentionGate:
|
|
"""require_mention + NIP-10 reply-to-own-message (#75826)."""
|
|
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = _make_adapter()
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
a._channel_state[CHANNEL] = a._new_channel_state("group")
|
|
return a
|
|
|
|
async def _poll_with(self, adapter, *events):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", list(events))
|
|
adapter._run_cli = cli
|
|
await adapter._poll_channel(CHANNEL)
|
|
|
|
def test_event_reply_parent_prefers_reply_marker(self):
|
|
ev = _tagged_event(
|
|
"child", CHANNEL, content="ok", root="root-id", reply_to="parent-id"
|
|
)
|
|
assert _event_reply_parent_id(ev) == "parent-id"
|
|
assert _event_reply_parent_id(
|
|
_tagged_event("c2", CHANNEL, content="ok", root="only-root")
|
|
) == "only-root"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_thread_reply_to_own_message_dispatches_without_mention(self, adapter):
|
|
# Live agent prompt lands first (self-echo is cached, not dispatched).
|
|
await self._poll_with(
|
|
adapter,
|
|
_tagged_event(
|
|
"agent-prompt",
|
|
CHANNEL,
|
|
content="⚠️ Dangerous command requires approval",
|
|
pubkey=SELF_PUBKEY,
|
|
created_at=10,
|
|
),
|
|
_tagged_event(
|
|
"user-reply",
|
|
CHANNEL,
|
|
content="sure go ahead",
|
|
root="agent-prompt",
|
|
reply_to="agent-prompt",
|
|
created_at=11,
|
|
),
|
|
)
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["user-reply"]
|
|
assert adapter._dispatched[0]["text"] == "sure go ahead"
|
|
assert adapter._dispatched[0]["reply_to_message_id"] == "agent-prompt"
|
|
assert adapter._dispatched[0]["reply_to_is_own_message"] is True
|
|
assert "approval" in (adapter._dispatched[0]["reply_to_text"] or "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_thread_reply_dispatches(self, adapter):
|
|
await self._poll_with(
|
|
adapter,
|
|
_tagged_event(
|
|
"agent-approve-prompt",
|
|
CHANNEL,
|
|
content="⚠️ Dangerous command requires approval",
|
|
pubkey=SELF_PUBKEY,
|
|
created_at=20,
|
|
),
|
|
_tagged_event(
|
|
"approve-msg",
|
|
CHANNEL,
|
|
content="/approve session",
|
|
root="agent-approve-prompt",
|
|
reply_to="agent-approve-prompt",
|
|
created_at=21,
|
|
),
|
|
)
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["approve-msg"]
|
|
assert adapter._dispatched[0]["text"] == "/approve session"
|
|
assert adapter._dispatched[0]["reply_to_is_own_message"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_to_other_user_stays_gated(self, adapter):
|
|
third = "c" * 64
|
|
await self._poll_with(
|
|
adapter,
|
|
_tagged_event(
|
|
"other-msg",
|
|
CHANNEL,
|
|
content="anyone around?",
|
|
pubkey=third,
|
|
created_at=30,
|
|
),
|
|
_tagged_event(
|
|
"reply-other",
|
|
CHANNEL,
|
|
content="yeah I'm here",
|
|
root="other-msg",
|
|
reply_to="other-msg",
|
|
created_at=31,
|
|
),
|
|
)
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_to_unknown_parent_stays_gated(self, adapter):
|
|
await self._poll_with(
|
|
adapter,
|
|
_tagged_event(
|
|
"orphan-reply",
|
|
CHANNEL,
|
|
content="/approve session",
|
|
root="never-seen",
|
|
reply_to="never-seen",
|
|
created_at=40,
|
|
),
|
|
)
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seeded_own_history_matches_thread_reply(self, adapter):
|
|
"""Replies to agent messages sent before a gateway restart still match."""
|
|
cli = _ScriptedCli()
|
|
cli.script(
|
|
"messages",
|
|
"get",
|
|
[
|
|
_tagged_event(
|
|
"pre-restart-agent",
|
|
CHANNEL,
|
|
content="⚠️ Dangerous command requires approval",
|
|
pubkey=SELF_PUBKEY,
|
|
created_at=50,
|
|
),
|
|
],
|
|
)
|
|
adapter._run_cli = cli
|
|
await adapter._seed_channel(CHANNEL, chat_type="group")
|
|
assert "pre-restart-agent" in adapter._channel_state[CHANNEL]["event_meta"]
|
|
assert adapter._dispatched == []
|
|
|
|
cli.responses.clear()
|
|
cli.script(
|
|
"messages",
|
|
"get",
|
|
[
|
|
_tagged_event(
|
|
"post-restart-approve",
|
|
CHANNEL,
|
|
content="/approve always",
|
|
root="pre-restart-agent",
|
|
reply_to="pre-restart-agent",
|
|
created_at=51,
|
|
),
|
|
],
|
|
)
|
|
await adapter._poll_channel(CHANNEL)
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["post-restart-approve"]
|
|
assert adapter._dispatched[0]["reply_to_is_own_message"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_recorded_id_matches_thread_reply(self, adapter):
|
|
"""send()'s returned event_id is cached even without a WS/poll echo."""
|
|
cli = _ScriptedCli()
|
|
cli.script(
|
|
"messages",
|
|
"send",
|
|
{"accepted": True, "event_id": "sent-prompt", "message": ""},
|
|
)
|
|
adapter._run_cli = cli
|
|
result = await adapter.send(
|
|
CHANNEL, "⚠️ Dangerous command requires approval"
|
|
)
|
|
assert result.success is True
|
|
assert "sent-prompt" in adapter._channel_state[CHANNEL]["event_meta"]
|
|
meta = adapter._channel_state[CHANNEL]["event_meta"]["sent-prompt"]
|
|
assert meta[0] == SELF_PUBKEY
|
|
|
|
cli.responses.clear()
|
|
cli.script(
|
|
"messages",
|
|
"get",
|
|
[
|
|
_tagged_event(
|
|
"reply-to-send",
|
|
CHANNEL,
|
|
content="/approve session",
|
|
root="sent-prompt",
|
|
reply_to="sent-prompt",
|
|
created_at=61,
|
|
),
|
|
],
|
|
)
|
|
await adapter._poll_channel(CHANNEL)
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["reply-to-send"]
|
|
assert adapter._dispatched[0]["reply_to_is_own_message"] is True
|
|
assert adapter._dispatched[0]["reply_to_message_id"] == "sent-prompt"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mention_path_still_populates_reply_context(self, adapter):
|
|
"""Visible @mention + thread reply still fills reply_to_* on dispatch."""
|
|
await self._poll_with(
|
|
adapter,
|
|
_tagged_event(
|
|
"agent-prior",
|
|
CHANNEL,
|
|
content="previous answer",
|
|
pubkey=SELF_PUBKEY,
|
|
created_at=70,
|
|
),
|
|
_tagged_event(
|
|
"mentioned-reply",
|
|
CHANNEL,
|
|
content="@Chip follow up please",
|
|
root="agent-prior",
|
|
reply_to="agent-prior",
|
|
created_at=71,
|
|
),
|
|
)
|
|
assert len(adapter._dispatched) == 1
|
|
d = adapter._dispatched[0]
|
|
assert d["message_id"] == "mentioned-reply"
|
|
assert d["text"] == "follow up please" # leading @Chip stripped
|
|
assert d["reply_to_message_id"] == "agent-prior"
|
|
assert d["reply_to_author_id"] == SELF_PUBKEY
|
|
assert d["reply_to_is_own_message"] is True
|
|
assert d["reply_to_text"] == "previous answer"
|
|
|
|
|
|
# ── DM classification via p-tags (issue #68871) ──────────────────────────
|
|
#
|
|
# `buzz dms list` returns [] on some hosted relays, so DM conversations leak
|
|
# in via `channels list` and get seeded chat_type="group". The adapter must
|
|
# reclassify them from the Nostr tags of real traffic: DM messages are
|
|
# p-tagged to our own pubkey WITHOUT the text mentioning us, while channel
|
|
# messages only ever p-tag us when the text visibly @mentions us.
|
|
|
|
|
|
class TestDmClassification:
|
|
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = _make_adapter()
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
# Metadata exactly as `channels list` returns it on the hosted relay.
|
|
a._channel_meta = {
|
|
DM_CHANNEL: {"channel_id": DM_CHANNEL, "name": "DM", "description": ""},
|
|
CHANNEL: {
|
|
"channel_id": CHANNEL,
|
|
"name": "general",
|
|
"description": "General conversation and community updates.",
|
|
},
|
|
}
|
|
a._channel_names = {DM_CHANNEL: "DM", CHANNEL: "general"}
|
|
# Both leaked in as group — the bug under test.
|
|
a._channel_state[DM_CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
a._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
return a
|
|
|
|
async def _poll_with(self, adapter, channel, *events):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", list(events))
|
|
adapter._run_cli = cli
|
|
await adapter._poll_channel(channel)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unmentioned_ptagged_dm_latches_and_dispatches(self, adapter):
|
|
"""The reported bug: a DM without an @mention must dispatch."""
|
|
await self._poll_with(
|
|
adapter, DM_CHANNEL,
|
|
_tagged_event("e1", DM_CHANNEL, content="here's a test message", p=SELF_PUBKEY),
|
|
)
|
|
assert adapter._channel_state[DM_CHANNEL]["chat_type"] == "dm"
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["e1"]
|
|
assert adapter._dispatched[0]["chat_type"] == "dm"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_general_reply_ptagging_self_stays_channel(self, adapter):
|
|
"""A #general reply to us p-tags our pubkey (observed live) — that
|
|
must NOT reclassify the channel; mention gating still applies."""
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event("e1", CHANNEL, content="@chip what's up?",
|
|
p=SELF_PUBKEY, reply_to="root-event"),
|
|
)
|
|
assert adapter._channel_state[CHANNEL]["chat_type"] == "group"
|
|
# It carried a mention, so it dispatches — but as a group message.
|
|
assert [d["chat_type"] for d in adapter._dispatched] == ["group"]
|
|
|
|
# And once the mention is absent, the channel gate drops the message
|
|
# even though the earlier reply p-tagged us.
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event("e2", CHANNEL, content="thanks everyone", created_at=1001),
|
|
)
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_ptag_dispatches_without_latching(self, adapter):
|
|
"""A signed recipient tag wakes a channel without turning it into a DM."""
|
|
adapter._channel_meta[CHANNEL]["description"] = ""
|
|
adapter._channel_meta[CHANNEL]["name"] = "announcements"
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event("e1", CHANNEL, content="fyi everyone", p=SELF_PUBKEY),
|
|
)
|
|
assert adapter._channel_state[CHANNEL]["chat_type"] == "group"
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["e1"]
|
|
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event(
|
|
"e2", CHANNEL, content="plain follow-up", created_at=1001, p=None
|
|
),
|
|
)
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["e1"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_metadata_never_latches_group_as_dm(self, adapter):
|
|
adapter._channel_meta.pop(CHANNEL)
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event("e1", CHANNEL, content="tag-only mention", p=SELF_PUBKEY),
|
|
)
|
|
assert adapter._channel_state[CHANNEL]["chat_type"] == "group"
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["e1"]
|
|
assert adapter._may_reclassify_as_dm(CHANNEL) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_shaped_channel_discovered_when_dms_list_empty(self):
|
|
"""Fallback discovery: with `dms list` broken (returns []), a
|
|
DM-shaped `channels list` entry gets watched. In watch-all mode a
|
|
real channel is adopted too (live join, #75107) — but seeded from
|
|
history, so nothing is replayed."""
|
|
a = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("dms", "list", [])
|
|
cli.script("channels", "list", [
|
|
{"channel_id": DM_CHANNEL, "name": "DM", "description": "", "created_at": 1},
|
|
{"channel_id": CHANNEL, "name": "general",
|
|
"description": "General conversation and community updates.", "created_at": 2},
|
|
])
|
|
a._run_cli = cli
|
|
await a._discover_dms(seed=False)
|
|
assert a._channel_state[DM_CHANNEL]["chat_type"] == "dm"
|
|
assert a._may_reclassify_as_dm(DM_CHANNEL) is True
|
|
# Watch-all mode: the real channel is live-adopted (seeded, never
|
|
# reclassified as DM).
|
|
assert CHANNEL in a._channel_state
|
|
assert a._channel_state[CHANNEL]["chat_type"] == "group"
|
|
assert a._may_reclassify_as_dm(CHANNEL) is False
|
|
# Adoption seeded it via a messages get call (history suppressed).
|
|
assert any(c[0][:2] == ["messages", "get"] for c in cli.calls)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_watch_list_blocks_live_channel_adoption(self):
|
|
"""With an explicit channels: list, discovery must NOT adopt real
|
|
channels outside that list — the user chose the watch set (#75107
|
|
scoping)."""
|
|
a = _make_adapter(extra={"channels": ["some-other-channel"]})
|
|
cli = _ScriptedCli()
|
|
cli.script("dms", "list", [])
|
|
cli.script("channels", "list", [
|
|
{"channel_id": CHANNEL, "name": "general",
|
|
"description": "General conversation and community updates.", "created_at": 2},
|
|
])
|
|
a._run_cli = cli
|
|
await a._discover_dms(seed=False)
|
|
assert CHANNEL not in a._channel_state
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_metadata_promotes_existing_group_without_recipient_tag(self, adapter):
|
|
cli = _ScriptedCli()
|
|
cli.script("dms", "list", [])
|
|
cli.script("channels", "list", [adapter._channel_meta[DM_CHANNEL]])
|
|
adapter._run_cli = cli
|
|
await adapter._discover_dms(seed=False)
|
|
assert adapter._channel_state[DM_CHANNEL]["chat_type"] == "dm"
|
|
|
|
await self._poll_with(
|
|
adapter, DM_CHANNEL,
|
|
_tagged_event("e1", DM_CHANNEL, content="no mention and no p tag"),
|
|
)
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["e1"]
|
|
assert adapter._dispatched[0]["chat_type"] == "dm"
|
|
|
|
|
|
class TestThreadRoots:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inbound_root_e_tag_propagates_to_session_source(self):
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {
|
|
"chat_type": "group",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
dispatched = []
|
|
|
|
async def capture(event):
|
|
dispatched.append(event)
|
|
|
|
adapter._message_handler = AsyncMock()
|
|
adapter.handle_message = capture
|
|
adapter._run_cli = _ScriptedCli()
|
|
adapter.send_reaction = AsyncMock(return_value=True)
|
|
event = _tagged_event("latest-child", CHANNEL, content="@Chip follow-up")
|
|
event["tags"] += [
|
|
["e", "stable-root", "", "root"],
|
|
["e", "latest-parent", "", "reply"],
|
|
]
|
|
|
|
await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event)
|
|
|
|
assert dispatched[0].source.thread_id == "stable-root"
|
|
|
|
|
|
# ── Sending ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBuzzAdapterSend:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_success_via_stdin(self):
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt123", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send(CHANNEL, "hello **markdown**")
|
|
assert result.success is True
|
|
assert result.message_id == "evt123"
|
|
|
|
args, stdin_text = cli.calls[0]
|
|
assert args[:2] == ["messages", "send"]
|
|
assert args[args.index("--channel") + 1] == CHANNEL
|
|
# Content travels via stdin (--content -), never argv
|
|
assert args[args.index("--content") + 1] == "-"
|
|
assert stdin_text == "hello **markdown**"
|
|
# Our own event id is marked seen for echo suppression
|
|
assert "evt123" in adapter._channel_state[CHANNEL]["seen"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_metadata_thread_id_uses_reply_to_flag(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt124", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send(
|
|
CHANNEL,
|
|
"working",
|
|
metadata={"thread_id": "buzz-event-123"},
|
|
)
|
|
|
|
assert result.success is True
|
|
args, _stdin = cli.calls[0]
|
|
assert args[args.index("--reply-to") + 1] == "buzz-event-123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_uses_metadata_reply_to_message_id(self):
|
|
"""Gateway stream/progress pass reply anchors via metadata.
|
|
|
|
Without honoring reply_to_message_id, mid-turn commentary posts as
|
|
new top-level channel messages instead of thread replies.
|
|
"""
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt-reply", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send(
|
|
CHANNEL,
|
|
"threaded reply",
|
|
metadata={"reply_to_message_id": "root-event-abc"},
|
|
)
|
|
assert result.success is True
|
|
args, _stdin = cli.calls[0]
|
|
assert "--reply-to" in args
|
|
assert args[args.index("--reply-to") + 1] == "root-event-abc"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_prefers_stable_thread_root_over_latest_reply(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt124"})
|
|
adapter._run_cli = cli
|
|
|
|
await adapter.send(
|
|
CHANNEL,
|
|
"threaded reply",
|
|
reply_to="latest-child",
|
|
metadata={"thread_id": "stable-root"},
|
|
)
|
|
|
|
args, _stdin = cli.calls[0]
|
|
assert args[args.index("--reply-to") + 1] == "stable-root"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("stdout", "error_fragment"),
|
|
[
|
|
("not json", "invalid CLI response"),
|
|
(json.dumps([]), "invalid CLI response"),
|
|
(json.dumps({"event_id": "evt"}), "invalid CLI response"),
|
|
(json.dumps({"accepted": True}), "invalid CLI response"),
|
|
(json.dumps({"accepted": True, "event_id": " "}), "invalid CLI response"),
|
|
],
|
|
)
|
|
async def test_send_rejects_invalid_zero_exit_receipt(self, stdout, error_fragment):
|
|
adapter = _make_adapter()
|
|
adapter._run_cli = AsyncMock(return_value=(0, stdout, ""))
|
|
|
|
result = await adapter.send(CHANNEL, "hello")
|
|
|
|
assert result.success is False
|
|
assert error_fragment in result.error
|
|
assert result.message_id is None
|
|
assert result.raw_response is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_rejection_is_useful_and_bounded(self):
|
|
adapter = _make_adapter()
|
|
adapter._run_cli = AsyncMock(
|
|
return_value=(
|
|
0,
|
|
json.dumps({"accepted": False, "message": "upload rejected " + "x" * 100_000}),
|
|
"",
|
|
)
|
|
)
|
|
|
|
result = await adapter.send(CHANNEL, "hello")
|
|
|
|
assert result.success is False
|
|
assert "upload rejected" in result.error
|
|
assert len(result.error) <= 1024
|
|
assert result.raw_response is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_success_exposes_only_verified_receipt(self):
|
|
adapter = _make_adapter()
|
|
adapter._run_cli = AsyncMock(
|
|
return_value=(
|
|
0,
|
|
json.dumps({
|
|
"accepted": True,
|
|
"event_id": " evt-safe ",
|
|
"message": "x" * 100_000,
|
|
"raw_response": "y" * 100_000,
|
|
}),
|
|
"",
|
|
)
|
|
)
|
|
|
|
result = await adapter.send(CHANNEL, "hello")
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "evt-safe"
|
|
assert result.raw_response is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_local_file_uses_file_flag(self, tmp_path):
|
|
img = tmp_path / "shot.png"
|
|
img.write_bytes(b"\x89PNG fake")
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt126", "message": ""})
|
|
adapter._run_cli = cli
|
|
result = await adapter.send_image(CHANNEL, str(img), caption="screenshot")
|
|
assert result.success is True
|
|
args, _stdin = cli.calls[0]
|
|
assert args[args.index("--file") + 1] == str(img)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_local_file_prefers_stable_thread_root(self, tmp_path):
|
|
img = tmp_path / "shot.png"
|
|
img.write_bytes(b"\x89PNG fake")
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt127"})
|
|
adapter._run_cli = cli
|
|
|
|
await adapter.send_image(
|
|
CHANNEL,
|
|
str(img),
|
|
reply_to="latest-child",
|
|
metadata={"thread_id": "stable-root"},
|
|
)
|
|
|
|
args, _stdin = cli.calls[0]
|
|
assert args[args.index("--reply-to") + 1] == "stable-root"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_retries_unresolved_presentation_mention_without_notifying(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script(
|
|
"messages",
|
|
"send",
|
|
"",
|
|
code=1,
|
|
stderr=(
|
|
"mention '@session' does not match a current channel member; "
|
|
"retry with --mention <pubkey>"
|
|
),
|
|
)
|
|
cli.script(
|
|
"messages",
|
|
"send",
|
|
{"accepted": True, "event_id": "evt124", "message": ""},
|
|
)
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send(
|
|
CHANNEL,
|
|
"Continue in @session:default/20260809_092321_24aa09.",
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "evt124"
|
|
# Composed with #83414 mention resolution: resolution probes
|
|
# (channels members / messages get) precede the sends; assert on the
|
|
# publish calls only.
|
|
sends = [c for c in cli.calls if tuple(c[0][:2]) == ("messages", "send")]
|
|
assert len(sends) == 2
|
|
assert sends[0][1] == (
|
|
"Continue in @session:default/20260809_092321_24aa09."
|
|
)
|
|
assert sends[1][1] == (
|
|
"Continue in @\u200bsession:default/20260809_092321_24aa09."
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_does_not_retry_unrelated_cli_failure(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script(
|
|
"messages",
|
|
"send",
|
|
"",
|
|
code=1,
|
|
stderr="relay unavailable",
|
|
)
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send(CHANNEL, "hello @session")
|
|
|
|
assert result.success is False
|
|
# Unrelated failures never retry the publish (mention-resolution
|
|
# probes for "@" content are not sends).
|
|
sends = [c for c in cli.calls if tuple(c[0][:2]) == ("messages", "send")]
|
|
assert len(sends) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_retries_unresolved_presentation_mention(self, tmp_path):
|
|
img = tmp_path / "shot.png"
|
|
img.write_bytes(b"\x89PNG fake")
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script(
|
|
"messages",
|
|
"send",
|
|
"",
|
|
code=1,
|
|
stderr=(
|
|
"mention '@session' does not match a current channel member; "
|
|
"retry with --mention <pubkey>"
|
|
),
|
|
)
|
|
cli.script(
|
|
"messages",
|
|
"send",
|
|
{"accepted": True, "event_id": "evt127", "message": ""},
|
|
)
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send_image(
|
|
CHANNEL,
|
|
str(img),
|
|
caption="See @session:default/example.",
|
|
)
|
|
|
|
assert result.success is True
|
|
assert len(cli.calls) == 2
|
|
assert cli.calls[0][1] == "See @session:default/example."
|
|
assert cli.calls[1][1] == "See @\u200bsession:default/example."
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_file_existing_local_path_stays_native_upload_after_probe_flip(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
img = tmp_path / "shot.png"
|
|
img.write_bytes(b"\x89PNG fake")
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt126b", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
original_is_file = _buzz_mod.Path.is_file
|
|
probe_results = iter([True, False])
|
|
|
|
def sequential_is_file(path):
|
|
if path == img:
|
|
return next(probe_results, original_is_file(path))
|
|
return original_is_file(path)
|
|
|
|
monkeypatch.setattr(_buzz_mod.Path, "is_file", sequential_is_file)
|
|
|
|
result = await adapter.send_image_file(CHANNEL, str(img), caption="screenshot")
|
|
|
|
assert result.success is True
|
|
args, stdin_text = cli.calls[0]
|
|
assert args[:2] == ["messages", "send"]
|
|
assert args[args.index("--channel") + 1] == CHANNEL
|
|
assert args[args.index("--file") + 1] == str(img)
|
|
assert args[args.index("--content") + 1] == "-"
|
|
assert stdin_text == "screenshot"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_file_uses_metadata_thread_id_when_reply_to_missing(self, tmp_path):
|
|
img = tmp_path / "reply-shot.png"
|
|
img.write_bytes(b"\x89PNG fake")
|
|
thread_id = "b" * 64
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt126c", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send_image_file(
|
|
CHANNEL,
|
|
str(img),
|
|
caption="screenshot",
|
|
metadata={"thread_id": thread_id},
|
|
)
|
|
|
|
assert result.success is True
|
|
args, stdin_text = cli.calls[0]
|
|
assert args[:2] == ["messages", "send"]
|
|
assert args[args.index("--channel") + 1] == CHANNEL
|
|
assert args[args.index("--file") + 1] == str(img)
|
|
assert args[args.index("--content") + 1] == "-"
|
|
assert args[args.index("--reply-to") + 1] == thread_id
|
|
assert stdin_text == "screenshot"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_file_missing_local_path_uses_base_fallback_notice(self, tmp_path):
|
|
missing = tmp_path / "missing.png"
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt127", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send_image_file(CHANNEL, str(missing), caption="screenshot")
|
|
|
|
assert result.success is True
|
|
args, stdin_text = cli.calls[0]
|
|
assert args[:2] == ["messages", "send"]
|
|
assert "--file" not in args
|
|
assert str(missing) not in args
|
|
assert stdin_text == "screenshot\n⚠️ Couldn't deliver the image attachment."
|
|
assert str(missing) not in stdin_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_document_uses_native_file_flag(self, tmp_path):
|
|
document = tmp_path / "package.zip"
|
|
document.write_bytes(b"PK fake")
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt128", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send_document(CHANNEL, str(document), caption="files")
|
|
|
|
assert result.success is True
|
|
args, stdin_text = cli.calls[0]
|
|
assert args[args.index("--file") + 1] == str(document)
|
|
assert stdin_text == "files"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_multiple_images_file_url_uses_native_file_send(self, tmp_path):
|
|
img = tmp_path / "shot with spaces.png"
|
|
img.write_bytes(b"\x89PNG fake")
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt127", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
await adapter.send_multiple_images(CHANNEL, [(img.as_uri(), "screenshot")])
|
|
|
|
assert len(cli.calls) == 1
|
|
args, stdin_text = cli.calls[0]
|
|
assert args[:2] == ["messages", "send"]
|
|
assert args[args.index("--channel") + 1] == CHANNEL
|
|
assert args[args.index("--file") + 1] == str(img)
|
|
assert args[args.index("--content") + 1] == "-"
|
|
assert stdin_text == "screenshot"
|
|
assert "Couldn't deliver the image attachment." not in stdin_text
|
|
|
|
|
|
|
|
|
|
# ── Thread anchoring ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestThreadAnchoring:
|
|
"""A reply must JOIN the thread it was triggered from, not nest a new one.
|
|
|
|
The gateway hands adapters the triggering message's own id as the reply
|
|
anchor. For a top-level message that correctly opens a thread; for a
|
|
message already inside a thread it used to nest a fresh sub-thread under
|
|
every answer (an endless ladder of one-message threads in Buzz).
|
|
"""
|
|
|
|
@staticmethod
|
|
def _event(eid, *e_tags):
|
|
return {"id": eid, "tags": [["h", CHANNEL], *e_tags]}
|
|
|
|
def test_top_level_message_has_no_root(self):
|
|
a = _make_adapter()
|
|
assert a._extract_thread_root(self._event("m1")) is None
|
|
|
|
def test_thread_opener_roots_at_its_parent(self):
|
|
a = _make_adapter()
|
|
ev = self._event("m2", ["e", "root1", "", "reply"])
|
|
assert a._extract_thread_root(ev) == "root1"
|
|
|
|
def test_in_thread_message_uses_root_marker_not_parent(self):
|
|
a = _make_adapter()
|
|
ev = self._event("m3", ["e", "root1", "", "root"], ["e", "m2", "", "reply"])
|
|
assert a._extract_thread_root(ev) == "root1"
|
|
|
|
def test_legacy_unmarked_etag_treated_as_parent(self):
|
|
a = _make_adapter()
|
|
assert a._extract_thread_root(self._event("m4", ["e", "root1"])) == "root1"
|
|
|
|
def test_reply_to_top_level_still_opens_a_thread(self):
|
|
"""Regression guard: the original (correct) behaviour must survive."""
|
|
a = _make_adapter()
|
|
a._record_thread_root("m1", self._event("m1"))
|
|
assert a._resolve_reply_anchor("m1") == "m1"
|
|
|
|
def test_reply_inside_thread_joins_that_thread(self):
|
|
a = _make_adapter()
|
|
a._record_thread_root("m3", self._event(
|
|
"m3", ["e", "root1", "", "root"], ["e", "m2", "", "reply"]))
|
|
assert a._resolve_reply_anchor("m3") == "root1"
|
|
|
|
def test_unknown_and_empty_anchors_pass_through(self):
|
|
a = _make_adapter()
|
|
assert a._resolve_reply_anchor("never-seen") == "never-seen"
|
|
assert a._resolve_reply_anchor(None) is None
|
|
|
|
def test_root_cache_is_bounded_and_evicts_oldest(self):
|
|
a = _make_adapter()
|
|
for i in range(a._THREAD_ROOT_CACHE + 50):
|
|
a._record_thread_root(f"id{i}", self._event(f"id{i}"))
|
|
assert len(a._thread_roots) == a._THREAD_ROOT_CACHE
|
|
assert "id0" not in a._thread_roots
|
|
assert f"id{a._THREAD_ROOT_CACHE + 49}" in a._thread_roots
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_anchors_to_root_not_trigger(self):
|
|
"""End-to-end through send(): argv must carry the thread root."""
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "dm", "last_ts": 0, "seen": {}}
|
|
adapter._record_thread_root("m3", self._event(
|
|
"m3", ["e", "root1", "", "root"], ["e", "m2", "", "reply"]))
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "e9", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
await adapter.send(CHANNEL, "in-thread answer", reply_to="m3")
|
|
args, _stdin = cli.calls[0]
|
|
assert args[args.index("--reply-to") + 1] == "root1"
|
|
|
|
|
|
# ── Inbound media localisation ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestInboundMediaLocalisation:
|
|
|
|
@staticmethod
|
|
def _capture_dispatch(adapter, *, failed_urls=None):
|
|
captured = []
|
|
cli_calls = []
|
|
failed_urls = set(failed_urls or [])
|
|
|
|
async def capture(event):
|
|
captured.append(event)
|
|
|
|
async def cli(args, *, input_text=None):
|
|
cli_calls.append(list(args))
|
|
assert args[:2] == ["media", "get"]
|
|
url = args[-1]
|
|
if url in failed_urls:
|
|
return 2, "", '{"error":"relay_error","message":"denied"}'
|
|
output_path = args[args.index("-o") + 1]
|
|
if url.endswith(".jpg"):
|
|
payload = b"\xff\xd8\xff\xe0JFIF test image"
|
|
elif url.endswith(".pdf"):
|
|
payload = b"%PDF-1.4\n% test document\n"
|
|
else:
|
|
payload = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
|
|
"+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
|
)
|
|
with open(output_path, "wb") as handle:
|
|
handle.write(payload)
|
|
return 0, "", ""
|
|
|
|
adapter.handle_message = capture
|
|
adapter._message_handler = AsyncMock()
|
|
adapter.send_reaction = AsyncMock(return_value=True)
|
|
adapter._run_cli = cli
|
|
# Localisation spends the agent's Buzz credentials, so it is gated on
|
|
# an explicit gateway authorization. The gateway registers this check
|
|
# on every adapter it constructs; tests are authorized by default and
|
|
# override the callback where the gate itself is under test.
|
|
adapter.set_authorization_check(lambda *_args: True)
|
|
return captured, cli_calls
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_markdown_relay_image_preserves_alt_text_before_dispatch(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
adapter = _make_adapter()
|
|
captured, cli_calls = self._capture_dispatch(adapter)
|
|
media_url = f"https://test.relay/media/{'a' * 64}.png"
|
|
|
|
await adapter._dispatch_message(
|
|
text=f"Please inspect this screenshot\n\n",
|
|
chat_id=CHANNEL,
|
|
chat_type="dm",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Joel",
|
|
message_id="media-event",
|
|
created_at=1000,
|
|
)
|
|
|
|
assert len(captured) == 1
|
|
event = captured[0]
|
|
assert event.text == "Please inspect this screenshot\n\nLogin error dialog"
|
|
assert event.message_type == MessageType.PHOTO
|
|
assert event.media_types == ["image/png"]
|
|
assert len(event.media_urls) == 1
|
|
assert event.media_urls[0].startswith(str(tmp_path / "hermes" / "cache"))
|
|
assert media_url not in event.text
|
|
assert cli_calls[0][-1] == media_url
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bare_relay_image_url_is_localised(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
adapter = _make_adapter()
|
|
captured, _calls = self._capture_dispatch(adapter)
|
|
media_url = f"https://test.relay/media/{'b' * 64}.png"
|
|
|
|
await adapter._dispatch_message(
|
|
text=f"What is in this? {media_url}",
|
|
chat_id=CHANNEL,
|
|
chat_type="dm",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Joel",
|
|
message_id="bare-media-event",
|
|
created_at=1001,
|
|
)
|
|
|
|
event = captured[0]
|
|
assert event.text == "What is in this?"
|
|
assert event.message_type == MessageType.PHOTO
|
|
assert event.media_types == ["image/png"]
|
|
assert len(event.media_urls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_image_only_message_gets_attachment_placeholder(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
adapter = _make_adapter()
|
|
captured, _calls = self._capture_dispatch(adapter)
|
|
media_url = f"https://test.relay/media/{'5' * 64}.png"
|
|
|
|
await adapter._dispatch_message(
|
|
text=f"",
|
|
chat_id=CHANNEL,
|
|
chat_type="dm",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Joel",
|
|
message_id="image-only-event",
|
|
created_at=1002,
|
|
)
|
|
|
|
event = captured[0]
|
|
assert event.text == "(attachment)"
|
|
assert event.message_type == MessageType.PHOTO
|
|
assert event.media_types == ["image/png"]
|
|
assert len(event.media_urls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_images_are_localised_in_content_order(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
adapter = _make_adapter()
|
|
captured, calls = self._capture_dispatch(adapter)
|
|
first = f"https://test.relay/media/{'c' * 64}.png"
|
|
second = f"https://test.relay/media/{'d' * 64}.jpg"
|
|
|
|
await adapter._dispatch_message(
|
|
text=f"Compare these\n\n{second}",
|
|
chat_id=CHANNEL,
|
|
chat_type="dm",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Joel",
|
|
message_id="multi-media-event",
|
|
created_at=1002,
|
|
)
|
|
|
|
event = captured[0]
|
|
assert event.text == "Compare these"
|
|
assert event.message_type == MessageType.PHOTO
|
|
assert event.media_types == ["image/png", "image/jpeg"]
|
|
assert len(event.media_urls) == 2
|
|
assert [call[-1] for call in calls] == [first, second]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_image_attachment_is_cached_as_document(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
adapter = _make_adapter()
|
|
captured, _calls = self._capture_dispatch(adapter)
|
|
media_url = f"https://test.relay/media/{'e' * 64}.pdf"
|
|
|
|
await adapter._dispatch_message(
|
|
text=f"Read this report\n{media_url}",
|
|
chat_id=CHANNEL,
|
|
chat_type="dm",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Joel",
|
|
message_id="document-media-event",
|
|
created_at=1003,
|
|
)
|
|
|
|
event = captured[0]
|
|
assert event.text == "Read this report"
|
|
assert event.message_type == MessageType.DOCUMENT
|
|
assert event.media_types == ["application/pdf"]
|
|
assert len(event.media_urls) == 1
|
|
assert "/cache/documents/" in event.media_urls[0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_failure_preserves_caption_and_alt_text(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
adapter = _make_adapter()
|
|
media_url = f"https://test.relay/media/{'f' * 64}.png"
|
|
captured, _calls = self._capture_dispatch(adapter, failed_urls=[media_url])
|
|
|
|
await adapter._dispatch_message(
|
|
text=f"The error is visible here\n",
|
|
chat_id=CHANNEL,
|
|
chat_type="dm",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Joel",
|
|
message_id="failed-media-event",
|
|
created_at=1004,
|
|
)
|
|
|
|
event = captured[0]
|
|
assert event.text == "The error is visible here\nCheckout error dialog"
|
|
assert event.message_type == MessageType.TEXT
|
|
assert event.media_urls == []
|
|
assert event.media_types == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_failed_download_does_not_drop_other_media(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
adapter = _make_adapter()
|
|
failed = f"https://test.relay/media/{'2' * 64}.png"
|
|
succeeded = f"https://test.relay/media/{'3' * 64}.jpg"
|
|
captured, calls = self._capture_dispatch(adapter, failed_urls=[failed])
|
|
|
|
await adapter._dispatch_message(
|
|
text=f"Compare what loaded\n\n",
|
|
chat_id=CHANNEL,
|
|
chat_type="dm",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Joel",
|
|
message_id="partial-media-event",
|
|
created_at=1005,
|
|
)
|
|
|
|
event = captured[0]
|
|
assert event.text == "Compare what loaded"
|
|
assert event.message_type == MessageType.PHOTO
|
|
assert event.media_types == ["image/jpeg"]
|
|
assert len(event.media_urls) == 1
|
|
assert [call[-1] for call in calls] == [failed, succeeded]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_event_handler_localises_media_before_gateway_dispatch(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[DM_CHANNEL] = {
|
|
"chat_type": "dm",
|
|
"last_ts": 0,
|
|
"seen": {},
|
|
}
|
|
captured = []
|
|
media_url = f"https://test.relay/media/{'4' * 64}.png"
|
|
|
|
async def capture(event):
|
|
captured.append(event)
|
|
|
|
async def cli(args, *, input_text=None):
|
|
if args[:2] == ["users", "get"]:
|
|
return 0, json.dumps([{"display_name": "Joel"}]), ""
|
|
assert args[:2] == ["media", "get"]
|
|
output_path = args[args.index("-o") + 1]
|
|
payload = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
|
|
"+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
|
)
|
|
with open(output_path, "wb") as handle:
|
|
handle.write(payload)
|
|
return 0, "", ""
|
|
|
|
adapter.handle_message = capture
|
|
adapter._message_handler = AsyncMock()
|
|
adapter.send_reaction = AsyncMock(return_value=True)
|
|
adapter._run_cli = cli
|
|
# As the gateway does for every adapter it constructs; the gate itself
|
|
# is covered by TestInboundMediaAuthorizationGate.
|
|
adapter.set_authorization_check(lambda *_args: True)
|
|
|
|
await adapter._handle_event(
|
|
DM_CHANNEL,
|
|
adapter._channel_state[DM_CHANNEL],
|
|
_tagged_event(
|
|
"handler-media-event",
|
|
DM_CHANNEL,
|
|
content=f"Can you read this? ",
|
|
p=SELF_PUBKEY,
|
|
),
|
|
)
|
|
|
|
assert len(captured) == 1
|
|
assert captured[0].text == "Can you read this?"
|
|
assert captured[0].message_type == MessageType.PHOTO
|
|
assert len(captured[0].media_urls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_media_url_is_left_untouched(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
adapter = _make_adapter()
|
|
captured, calls = self._capture_dispatch(adapter)
|
|
media_url = f"https://cdn.example/media/{'1' * 64}.png"
|
|
text = f"External reference: "
|
|
|
|
await adapter._dispatch_message(
|
|
text=text,
|
|
chat_id=CHANNEL,
|
|
chat_type="dm",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Joel",
|
|
message_id="external-media-event",
|
|
created_at=1006,
|
|
)
|
|
|
|
event = captured[0]
|
|
assert event.text == text
|
|
assert event.message_type == MessageType.TEXT
|
|
assert event.media_urls == []
|
|
assert calls == []
|
|
|
|
|
|
class TestInboundMediaAuthorizationGate:
|
|
"""Authenticated retrieval must never run for an unauthorized sender.
|
|
|
|
``buzz media get`` signs the request with this agent's own key, so a
|
|
relay object named by an unauthorized sender must not be fetched or
|
|
cached. Every non-``True`` outcome — denial, no registered check, a
|
|
raising check, or a truthy non-boolean — must fail closed and leave the
|
|
message text exactly as it arrived.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _media_text():
|
|
return f"look at this "
|
|
|
|
async def _dispatch_with_check(self, adapter, monkeypatch, tmp_path, check):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
captured, cli_calls = TestInboundMediaLocalisation._capture_dispatch(adapter)
|
|
adapter.set_authorization_check(check)
|
|
text = self._media_text()
|
|
|
|
await adapter._dispatch_message(
|
|
text=text,
|
|
chat_id=CHANNEL,
|
|
chat_type="group",
|
|
user_id=OTHER_PUBKEY,
|
|
user_name="Joel",
|
|
message_id="gated-media-event",
|
|
created_at=1007,
|
|
)
|
|
return captured, cli_calls, text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_denied_sender_media_is_not_downloaded(self, monkeypatch, tmp_path):
|
|
adapter = _make_adapter()
|
|
captured, cli_calls, text = await self._dispatch_with_check(
|
|
adapter, monkeypatch, tmp_path, lambda *_args: False
|
|
)
|
|
|
|
assert cli_calls == []
|
|
assert captured[0].text == text
|
|
assert captured[0].message_type == MessageType.TEXT
|
|
assert captured[0].media_urls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_authorization_check_blocks_download(self, monkeypatch, tmp_path):
|
|
adapter = _make_adapter()
|
|
captured, cli_calls, text = await self._dispatch_with_check(
|
|
adapter, monkeypatch, tmp_path, None
|
|
)
|
|
|
|
assert cli_calls == []
|
|
assert captured[0].text == text
|
|
assert captured[0].media_urls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raising_authorization_check_blocks_download(self, monkeypatch, tmp_path):
|
|
def boom(*_args):
|
|
raise RuntimeError("auth backend down")
|
|
|
|
adapter = _make_adapter()
|
|
captured, cli_calls, text = await self._dispatch_with_check(
|
|
adapter, monkeypatch, tmp_path, boom
|
|
)
|
|
|
|
assert cli_calls == []
|
|
assert captured[0].text == text
|
|
assert captured[0].media_urls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_truthy_non_boolean_is_not_an_authorization(self, monkeypatch, tmp_path):
|
|
"""A non-boolean result must not be coerced into a credentialed fetch."""
|
|
adapter = _make_adapter()
|
|
captured, cli_calls, text = await self._dispatch_with_check(
|
|
adapter, monkeypatch, tmp_path, lambda *_args: "allowed"
|
|
)
|
|
|
|
assert cli_calls == []
|
|
assert captured[0].text == text
|
|
assert captured[0].media_urls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adapter_allowlist_does_not_override_gateway_denial(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
"""``allowed_users`` is a pre-filter, not a second source of truth."""
|
|
adapter = _make_adapter({"allowed_users": [OTHER_PUBKEY]})
|
|
captured, cli_calls, _text = await self._dispatch_with_check(
|
|
adapter, monkeypatch, tmp_path, lambda *_args: False
|
|
)
|
|
|
|
assert OTHER_PUBKEY in adapter._allowed_pubkeys
|
|
assert cli_calls == []
|
|
assert captured[0].media_urls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorized_sender_still_downloads(self, monkeypatch, tmp_path):
|
|
"""The gate must not break the happy path it protects."""
|
|
adapter = _make_adapter()
|
|
captured, cli_calls, _text = await self._dispatch_with_check(
|
|
adapter, monkeypatch, tmp_path, lambda *_args: True
|
|
)
|
|
|
|
assert len(cli_calls) == 1
|
|
assert captured[0].message_type == MessageType.PHOTO
|
|
assert len(captured[0].media_urls) == 1
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("method_name", "suffix"),
|
|
[
|
|
("send_image_file", ".png"),
|
|
("send_video", ".mp4"),
|
|
("send_voice", ".ogg"),
|
|
("send_document", ".pdf"),
|
|
],
|
|
)
|
|
async def test_live_media_capabilities_upload_local_file_with_caption_and_reply(
|
|
self, tmp_path, method_name, suffix
|
|
):
|
|
media = tmp_path / f"attachment{suffix}"
|
|
media.write_bytes(b"media")
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt-media"})
|
|
adapter._run_cli = cli
|
|
|
|
result = await getattr(adapter, method_name)(
|
|
CHANNEL,
|
|
str(media),
|
|
caption="caption",
|
|
reply_to="latest-child",
|
|
metadata={"thread_id": "stable-root"},
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "evt-media"
|
|
args, stdin_text = cli.calls[0]
|
|
assert args[args.index("--file") + 1] == str(media)
|
|
# Threading contract (#99429): stable thread root beats latest-child.
|
|
assert args[args.index("--reply-to") + 1] == "stable-root"
|
|
assert stdin_text == "caption"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_media_rejects_zero_exit_without_verified_event_id(self, tmp_path):
|
|
media = tmp_path / "attachment.pdf"
|
|
media.write_bytes(b"media")
|
|
adapter = _make_adapter()
|
|
adapter._run_cli = AsyncMock(
|
|
return_value=(0, json.dumps({"accepted": True}), "")
|
|
)
|
|
|
|
result = await adapter.send_document(CHANNEL, str(media))
|
|
|
|
assert result.success is False
|
|
assert result.error == "invalid CLI response"
|
|
assert result.raw_response is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_media_redacts_long_path_before_bounding(self, tmp_path):
|
|
parent = tmp_path
|
|
private_parts = []
|
|
for index in range(6):
|
|
part = f"private-{index}-" + ("x" * 150)
|
|
private_parts.append(part)
|
|
parent = parent / part
|
|
parent.mkdir()
|
|
media = parent / "handoff.txt"
|
|
media.write_text("safe handoff", encoding="utf-8")
|
|
adapter = _make_adapter()
|
|
adapter._run_cli = AsyncMock(
|
|
return_value=(
|
|
2,
|
|
"",
|
|
json.dumps(
|
|
{
|
|
"error": "network",
|
|
"message": f"upload failed for {media}: " + ("z" * 1_000),
|
|
}
|
|
),
|
|
)
|
|
)
|
|
|
|
result = await adapter.send_document(CHANNEL, str(media))
|
|
|
|
assert result.success is False
|
|
assert all(part not in result.error for part in private_parts)
|
|
assert "handoff.txt" in result.error
|
|
assert len(result.error) <= 900
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_to_platform_live_buzz_delivers_all_media(self, monkeypatch, tmp_path):
|
|
from gateway.config import Platform
|
|
from tools.send_message_tool import _send_to_platform
|
|
|
|
first = tmp_path / "first.txt"
|
|
second = tmp_path / "second.pdf"
|
|
first.write_text("first", encoding="utf-8")
|
|
second.write_text("second", encoding="utf-8")
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt-text"})
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt-first"})
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt-second"})
|
|
adapter._run_cli = cli
|
|
platform = Platform("buzz")
|
|
runner = SimpleNamespace(adapters={platform: adapter})
|
|
monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: runner)
|
|
|
|
result = await _send_to_platform(
|
|
platform,
|
|
SimpleNamespace(enabled=True, token=None, extra={}),
|
|
CHANNEL,
|
|
"attached files",
|
|
thread_id="root-event",
|
|
media_files=[(str(first), False), (str(second), False)],
|
|
)
|
|
|
|
assert result == {
|
|
"success": True,
|
|
"message_id": "evt-second",
|
|
"media_delivered": True,
|
|
}
|
|
assert len(cli.calls) == 3
|
|
assert cli.calls[0][1] == "attached files"
|
|
assert [call[0][call[0].index("--file") + 1] for call in cli.calls[1:]] == [
|
|
str(first),
|
|
str(second),
|
|
]
|
|
assert all(
|
|
call[0][call[0].index("--reply-to") + 1] == "root-event"
|
|
for call in cli.calls
|
|
)
|
|
|
|
|
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBuzzAdapterLifecycle:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_releases_scoped_lock(self, monkeypatch):
|
|
"""The identity lock taken in connect() must be released on disconnect."""
|
|
import gateway.status as gateway_status
|
|
|
|
released = []
|
|
monkeypatch.setattr(
|
|
gateway_status,
|
|
"release_scoped_lock",
|
|
lambda platform, key: released.append((platform, key)),
|
|
)
|
|
adapter = _make_adapter()
|
|
adapter._lock_key = "wss://relay.example:" + SELF_PUBKEY
|
|
await adapter.disconnect()
|
|
assert released == [("buzz", "wss://relay.example:" + SELF_PUBKEY)]
|
|
assert adapter._lock_key is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_fails_when_identity_lock_held(self, monkeypatch):
|
|
"""A second profile using the same relay+pubkey must fail fast."""
|
|
import gateway.status as gateway_status
|
|
|
|
monkeypatch.setattr(
|
|
gateway_status, "acquire_scoped_lock", lambda platform, key: False
|
|
)
|
|
adapter = _make_adapter()
|
|
adapter.cli_path = "/fake/buzz"
|
|
monkeypatch.setattr(_buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1test")
|
|
cli = _ScriptedCli()
|
|
cli.script(
|
|
"users", "get",
|
|
[{"pubkey": SELF_PUBKEY, "display_name": "Chip"}],
|
|
)
|
|
adapter._run_cli = cli
|
|
assert await adapter.connect() is False
|
|
assert adapter._lock_key is None
|
|
|
|
|
|
# ── Credentials / requirements ────────────────────────────────────────────
|
|
|
|
|
|
class TestCredentialResolution:
|
|
|
|
def test_env_key_wins(self, monkeypatch):
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1fromenv")
|
|
assert _resolve_private_key() == "nsec1fromenv"
|
|
|
|
def test_credentials_file_fallback(self, monkeypatch, tmp_path):
|
|
creds = tmp_path / "agent_credentials.json"
|
|
creds.write_text(json.dumps({"nsec": "nsec1fromfile", "npub": "npub1x"}), encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(creds))
|
|
assert _resolve_private_key() == "nsec1fromfile"
|
|
|
|
def test_owner_auth_tag_from_credentials_file(self, monkeypatch, tmp_path):
|
|
tag = ["auth", "b" * 64, "", "c" * 128]
|
|
creds = tmp_path / "agent_credentials.json"
|
|
creds.write_text(json.dumps({"nsec": "nsec1fromfile", "auth_tag": tag}), encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(creds))
|
|
assert json.loads(_resolve_auth_tag()) == tag
|
|
|
|
def test_invalid_owner_auth_tag_fails_closed(self, monkeypatch, tmp_path):
|
|
creds = tmp_path / "agent_credentials.json"
|
|
creds.write_text(json.dumps({"nsec": "nsec1fromfile", "auth_tag": ["bad"]}), encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(creds))
|
|
with pytest.raises(ValueError, match="auth tag"):
|
|
_resolve_auth_tag()
|
|
|
|
def test_scoped_credentials_and_tag_do_not_borrow_ambient_profile(self, monkeypatch, tmp_path):
|
|
from agent import secret_scope as ss
|
|
|
|
ambient_tag = ["auth", "a" * 64, "", "d" * 128]
|
|
scoped_tag = ["auth", "b" * 64, "", "c" * 128]
|
|
ambient = tmp_path / "ambient.json"
|
|
scoped = tmp_path / "scoped.json"
|
|
ambient.write_text(json.dumps({"nsec": "nsec1ambient", "auth_tag": ambient_tag}))
|
|
scoped.write_text(json.dumps({"nsec": "nsec1scoped", "auth_tag": scoped_tag}))
|
|
monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(ambient))
|
|
monkeypatch.setenv("BUZZ_AUTH_TAG", json.dumps(ambient_tag))
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({"BUZZ_CREDENTIALS_FILE": str(scoped)})
|
|
try:
|
|
assert _resolve_private_key() == "nsec1scoped"
|
|
assert json.loads(_resolve_auth_tag()) == scoped_tag
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
ss.set_multiplex_active(False)
|
|
|
|
def test_owner_auth_tag_uses_credentials_autodiscovery(self, monkeypatch, tmp_path):
|
|
tag = ["auth", "b" * 64, "", "c" * 128]
|
|
creds = tmp_path / "agent_credentials.json"
|
|
creds.write_text(json.dumps({"nsec": "nsec1fromfile", "auth_tag": tag}), encoding="utf-8")
|
|
monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path)
|
|
assert _resolve_private_key() == "nsec1fromfile"
|
|
assert json.loads(_resolve_auth_tag()) == tag
|
|
|
|
def test_empty_multiplex_scope_never_autodiscovers_ambient_credentials(self, monkeypatch, tmp_path):
|
|
from agent import secret_scope as ss
|
|
|
|
tag = ["auth", "a" * 64, "", "d" * 128]
|
|
(tmp_path / "general_credentials.json").write_text(
|
|
json.dumps({"nsec": "nsec1ambient", "auth_tag": tag}), encoding="utf-8"
|
|
)
|
|
monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path)
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({})
|
|
try:
|
|
assert _resolve_private_key() == ""
|
|
assert _resolve_auth_tag() == ""
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
ss.set_multiplex_active(False)
|
|
|
|
|
|
# ── Env enablement / registration / standalone send ──────────────────────
|
|
|
|
|
|
class TestEnvEnablement:
|
|
|
|
def test_returns_none_when_unconfigured(self):
|
|
assert _env_enablement() is None
|
|
|
|
|
|
class TestBuzzPluginRegistration:
|
|
|
|
def test_register_platform_contract(self):
|
|
from gateway.platform_registry import platform_registry
|
|
|
|
platform_registry.unregister("buzz")
|
|
ctx = MagicMock()
|
|
register(ctx)
|
|
ctx.register_platform.assert_called_once()
|
|
kwargs = ctx.register_platform.call_args.kwargs
|
|
assert kwargs["name"] == "buzz"
|
|
assert kwargs["cron_deliver_env_var"] == "BUZZ_HOME_CHANNEL"
|
|
assert kwargs["allowed_users_env"] == "BUZZ_ALLOWED_USERS"
|
|
assert kwargs["allow_all_env"] == "BUZZ_ALLOW_ALL_USERS"
|
|
assert callable(kwargs["standalone_sender_fn"])
|
|
assert callable(kwargs["env_enablement_fn"])
|
|
assert set(kwargs["required_env"]) == {"BUZZ_RELAY_URL", "BUZZ_PRIVATE_KEY"}
|
|
|
|
|
|
class TestStandaloneSend:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_success(self, monkeypatch, tmp_path):
|
|
from gateway.config import PlatformConfig
|
|
|
|
fake_cli = tmp_path / "buzz"
|
|
fake_cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli))
|
|
|
|
captured = {}
|
|
|
|
async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=30.0):
|
|
captured.update(cli_path=cli_path, args=args, relay_url=relay_url, auth_tag=auth_tag, input_text=input_text)
|
|
return 0, json.dumps({"accepted": True, "event_id": "evt-cron", "message": ""}), ""
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
|
|
result = await _standalone_send(PlatformConfig(enabled=True, extra={}), CHANNEL, "cron says hi")
|
|
assert result == {"success": True, "message_id": "evt-cron"}
|
|
assert captured["args"][:2] == ["messages", "send"]
|
|
assert captured["input_text"] == "cron says hi"
|
|
# The private key must never be part of argv
|
|
assert all("nsec1x" not in str(a) for a in captured["args"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_injects_owner_auth_tag_from_credentials_file(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
"""Cron/standalone path must load NIP-OA auth_tag from credentials JSON.
|
|
|
|
Main-line regression: when only BUZZ_PRIVATE_KEY is ambient and the
|
|
credentials file holds auth_tag, omitting injection causes relay 403
|
|
membership failures on owner-gated relays.
|
|
"""
|
|
from gateway.config import PlatformConfig
|
|
|
|
tag = ["auth", "b" * 64, "", "c" * 128]
|
|
creds = tmp_path / "agent_credentials.json"
|
|
creds.write_text(
|
|
json.dumps({"nsec": "nsec1fromfile", "auth_tag": tag}),
|
|
encoding="utf-8",
|
|
)
|
|
fake_cli = tmp_path / "buzz"
|
|
fake_cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli))
|
|
monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(creds))
|
|
monkeypatch.delenv("BUZZ_PRIVATE_KEY", raising=False)
|
|
monkeypatch.delenv("BUZZ_AUTH_TAG", raising=False)
|
|
|
|
captured = {}
|
|
|
|
async def fake_exec(
|
|
cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=30.0
|
|
):
|
|
captured.update(
|
|
private_key=private_key,
|
|
auth_tag=auth_tag,
|
|
args=args,
|
|
input_text=input_text,
|
|
)
|
|
return 0, json.dumps({"accepted": True, "event_id": "evt-auth", "message": ""}), ""
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
|
|
result = await _standalone_send(
|
|
PlatformConfig(enabled=True, extra={}), CHANNEL, "cron needs owner auth"
|
|
)
|
|
assert result == {"success": True, "message_id": "evt-auth"}
|
|
assert captured["private_key"] == "nsec1fromfile"
|
|
assert json.loads(captured["auth_tag"]) == tag
|
|
# Secrets stay out of argv (auth_tag is env-injected by _exec_buzz).
|
|
joined_args = " ".join(str(a) for a in captured["args"])
|
|
assert "nsec1fromfile" not in joined_args
|
|
assert tag[1] not in joined_args
|
|
assert tag[3] not in joined_args
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_key_only_does_not_invent_auth_tag(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
"""Direct private key without credentials_file must not invent an auth tag."""
|
|
from gateway.config import PlatformConfig
|
|
|
|
fake_cli = tmp_path / "buzz"
|
|
fake_cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli))
|
|
monkeypatch.delenv("BUZZ_AUTH_TAG", raising=False)
|
|
monkeypatch.delenv("BUZZ_CREDENTIALS_FILE", raising=False)
|
|
# Ambient credentials dir must not be borrowed when a direct key is set.
|
|
ambient = tmp_path / "ambient_credentials.json"
|
|
ambient.write_text(
|
|
json.dumps({"nsec": "nsec1ambient", "auth_tag": ["auth", "a" * 64, "", "d" * 128]}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path)
|
|
|
|
captured = {}
|
|
|
|
async def fake_exec(
|
|
cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=30.0
|
|
):
|
|
captured.update(private_key=private_key, auth_tag=auth_tag)
|
|
return 0, json.dumps({"accepted": True, "event_id": "evt-key", "message": ""}), ""
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
result = await _standalone_send(
|
|
PlatformConfig(enabled=True, extra={}), CHANNEL, "key only"
|
|
)
|
|
assert result == {"success": True, "message_id": "evt-key"}
|
|
assert captured["private_key"] == "nsec1x"
|
|
assert captured["auth_tag"] == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_retries_unresolved_presentation_mention(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
from gateway.config import PlatformConfig
|
|
|
|
fake_cli = tmp_path / "buzz"
|
|
fake_cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli))
|
|
sent = []
|
|
|
|
async def fake_exec(
|
|
cli_path,
|
|
args,
|
|
*,
|
|
relay_url,
|
|
private_key,
|
|
auth_tag="",
|
|
input_text=None,
|
|
timeout=30.0,
|
|
):
|
|
sent.append(input_text)
|
|
if len(sent) == 1:
|
|
return (
|
|
1,
|
|
"",
|
|
"user_error: mention '@session' does not match a current "
|
|
"channel member; retry with --mention <pubkey>",
|
|
)
|
|
return (
|
|
0,
|
|
json.dumps(
|
|
{"accepted": True, "event_id": "evt-standalone", "message": ""}
|
|
),
|
|
"",
|
|
)
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
|
|
result = await _standalone_send(
|
|
PlatformConfig(enabled=True, extra={}),
|
|
CHANNEL,
|
|
"See @session:default/example.",
|
|
)
|
|
|
|
assert result == {"success": True, "message_id": "evt-standalone"}
|
|
assert sent == [
|
|
"See @session:default/example.",
|
|
"See @\u200bsession:default/example.",
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_extracts_path_from_media_descriptor(self, monkeypatch, tmp_path):
|
|
from gateway.config import PlatformConfig
|
|
|
|
fake_cli = tmp_path / "buzz"
|
|
fake_cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
document = tmp_path / "report.txt"
|
|
document.write_text("report", encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli))
|
|
|
|
captured = {}
|
|
|
|
async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=30.0):
|
|
captured["args"] = args
|
|
return 0, json.dumps({"accepted": True, "event_id": "evt-media"}), ""
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
|
|
result = await _standalone_send(
|
|
PlatformConfig(enabled=True, extra={}),
|
|
CHANNEL,
|
|
"attached",
|
|
media_files=[(str(document), False)],
|
|
)
|
|
|
|
assert result == {
|
|
"success": True,
|
|
"message_id": "evt-media",
|
|
"media_delivered": True,
|
|
}
|
|
file_index = captured["args"].index("--file")
|
|
assert captured["args"][file_index + 1] == str(document)
|
|
|
|
|
|
|
|
|
|
# ── Editing and deleting (streaming) ──────────────────────────────────
|
|
|
|
|
|
class TestBuzzAdapterEdit:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_targets_the_original_event_and_uses_stdin(self):
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "edit", {"accepted": True, "event_id": "edit1", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.edit_message(CHANNEL, "orig1", "partial answer")
|
|
assert result.success is True
|
|
|
|
args, stdin_text = cli.calls[0]
|
|
assert args[:2] == ["messages", "edit"]
|
|
assert args[args.index("--event") + 1] == "orig1"
|
|
# Content travels via stdin (--content -), never argv, same as send
|
|
assert args[args.index("--content") + 1] == "-"
|
|
assert stdin_text == "partial answer"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_returns_the_original_id_not_the_cli_event_id(self):
|
|
"""The stream consumer re-edits ONE message id for the whole stream.
|
|
|
|
buzz-cli reports a fresh event id for each edit; returning that would
|
|
make the second edit address a message that was never sent.
|
|
"""
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "edit", {"accepted": True, "event_id": "edit1"})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.edit_message(CHANNEL, "orig1", "text")
|
|
assert result.message_id == "orig1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_marks_its_own_event_seen(self):
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "edit", {"accepted": True, "event_id": "edit1"})
|
|
adapter._run_cli = cli
|
|
|
|
await adapter.edit_message(CHANNEL, "orig1", "text")
|
|
assert "edit1" in adapter._channel_state[CHANNEL]["seen"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_accepts_finalize_without_changing_behaviour(self):
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "edit", {"accepted": True, "event_id": "edit1"})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.edit_message(CHANNEL, "orig1", "text", finalize=True)
|
|
assert result.success is True
|
|
assert len(cli.calls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_without_a_message_id_never_calls_the_cli(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.edit_message(CHANNEL, "", "text")
|
|
assert result.success is False
|
|
assert cli.calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_with_empty_content_never_calls_the_cli(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.edit_message(CHANNEL, "orig1", "")
|
|
assert result.success is False
|
|
assert cli.calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_relay_error_is_retryable_but_bad_input_is_not(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "edit", "", code=2, stderr="relay unreachable")
|
|
adapter._run_cli = cli
|
|
relay_failure = await adapter.edit_message(CHANNEL, "orig1", "text")
|
|
assert relay_failure.success is False
|
|
assert relay_failure.retryable is True
|
|
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "edit", "", code=1, stderr="bad input")
|
|
adapter._run_cli = cli
|
|
input_failure = await adapter.edit_message(CHANNEL, "orig1", "text")
|
|
assert input_failure.success is False
|
|
assert input_failure.retryable is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_targets_the_event(self):
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "delete", {"accepted": True, "event_id": "del1"})
|
|
adapter._run_cli = cli
|
|
|
|
assert await adapter.delete_message(CHANNEL, "orig1") is True
|
|
assert cli.calls[0][0] == ["messages", "delete", "--event", "orig1"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_failure_returns_false(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "delete", "", code=2, stderr="relay unreachable")
|
|
adapter._run_cli = cli
|
|
|
|
assert await adapter.delete_message(CHANNEL, "orig1") is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_without_a_message_id_never_calls_the_cli(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
adapter._run_cli = cli
|
|
|
|
assert await adapter.delete_message(CHANNEL, "") is False
|
|
assert cli.calls == []
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"stdout",
|
|
[
|
|
"not json",
|
|
json.dumps([]),
|
|
json.dumps({"event_id": "evt"}),
|
|
json.dumps({"accepted": True}),
|
|
json.dumps({"accepted": True, "event_id": ""}),
|
|
],
|
|
)
|
|
async def test_standalone_send_rejects_invalid_zero_exit_receipt(
|
|
self, monkeypatch, tmp_path, stdout
|
|
):
|
|
from gateway.config import PlatformConfig
|
|
|
|
fake_cli = tmp_path / "buzz"
|
|
fake_cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli))
|
|
monkeypatch.setattr(
|
|
_buzz_mod,
|
|
"_exec_buzz",
|
|
AsyncMock(return_value=(0, stdout, "")),
|
|
)
|
|
|
|
result = await _standalone_send(
|
|
PlatformConfig(enabled=True, extra={}), CHANNEL, "hello"
|
|
)
|
|
|
|
assert result == {"error": "Buzz standalone send failed: invalid CLI response"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_rejection_is_useful_bounded_and_not_delivered(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
from gateway.config import PlatformConfig
|
|
|
|
fake_cli = tmp_path / "buzz"
|
|
fake_cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli))
|
|
monkeypatch.setattr(
|
|
_buzz_mod,
|
|
"_exec_buzz",
|
|
AsyncMock(
|
|
return_value=(
|
|
0,
|
|
json.dumps({"accepted": False, "message": "upload rejected " + "x" * 100_000}),
|
|
"",
|
|
)
|
|
),
|
|
)
|
|
|
|
result = await _standalone_send(
|
|
PlatformConfig(enabled=True, extra={}),
|
|
CHANNEL,
|
|
"hello",
|
|
media_files=[("/tmp/report.txt", False)],
|
|
)
|
|
|
|
assert "upload rejected" in result["error"]
|
|
assert len(result["error"]) <= 1024
|
|
assert "success" not in result
|
|
assert "media_delivered" not in result
|
|
# ── Durable channel cursors across restart (#90464) ───────────────────────
|
|
|
|
|
|
class TestChannelCursorPersistence:
|
|
"""A restart must resume from the saved cursor, not reseed from history.
|
|
|
|
Seeding marks every event currently in the channel as seen. Anything that
|
|
arrived while the gateway was down is in that history, so an unconditional
|
|
reseed swallows it permanently even though the relay still has it.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def adapter(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
a = _make_adapter()
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
return a
|
|
|
|
@staticmethod
|
|
def _cursor_file(tmp_path):
|
|
return tmp_path / "buzz" / "channel-cursors.json"
|
|
|
|
async def _seed(self, adapter, *events):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", list(events))
|
|
adapter._run_cli = cli
|
|
adapter._load_cursors()
|
|
await adapter._seed_channel(CHANNEL, chat_type="group")
|
|
adapter._save_cursors()
|
|
return cli
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seed_writes_a_cursor(self, adapter, tmp_path):
|
|
await self._seed(adapter, _event("e1", created_at=100), _event("e2", created_at=200))
|
|
|
|
saved = json.loads(self._cursor_file(tmp_path).read_text(encoding="utf-8"))
|
|
assert saved["identity"] == SELF_PUBKEY
|
|
assert saved["relay"] == "https://test.relay"
|
|
assert saved["channels"][CHANNEL]["last_ts"] == 200
|
|
assert saved["channels"][CHANNEL]["seen"] == ["e1", "e2"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_resumes_instead_of_reseeding(self, adapter, tmp_path, monkeypatch):
|
|
await self._seed(adapter, _event("e1", content="@Chip first", created_at=100))
|
|
|
|
# Restart. The relay now also holds a mention that landed while the
|
|
# gateway was down, and it sits in the same history a reseed reads.
|
|
restarted = _make_adapter()
|
|
restarted._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
restarted._dispatched.append(kwargs)
|
|
|
|
restarted._dispatch_message = capture
|
|
restarted._message_handler = AsyncMock()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [
|
|
_event("e1", content="@Chip first", created_at=100),
|
|
_event("e2", content="@Chip sent while you were down", created_at=150),
|
|
])
|
|
restarted._run_cli = cli
|
|
restarted._load_cursors()
|
|
await restarted._seed_channel(CHANNEL, chat_type="group")
|
|
|
|
# Restoring must not spend a CLI call on history it is not going to use.
|
|
assert cli.calls == []
|
|
state = restarted._channel_state[CHANNEL]
|
|
assert set(state["seen"]) == {"e1"}
|
|
assert state["last_ts"] == 100
|
|
|
|
# The first poll after the restart delivers the missed mention.
|
|
await restarted._poll_channel(CHANNEL)
|
|
assert [d["message_id"] for d in restarted._dispatched] == ["e2"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cursor_survives_only_for_the_same_identity_and_relay(self, adapter, tmp_path, monkeypatch):
|
|
await self._seed(adapter, _event("e1", created_at=100))
|
|
|
|
# Same machine, different bot: channel ids would collide but the event
|
|
# stream behind them is another one, so the cursor must be ignored.
|
|
other = _make_adapter()
|
|
other._self_pubkey = OTHER_PUBKEY
|
|
other._load_cursors()
|
|
assert other._restored_cursors == {}
|
|
|
|
elsewhere = _make_adapter({"relay_url": "https://other.relay"})
|
|
elsewhere._load_cursors()
|
|
assert elsewhere._restored_cursors == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unreadable_cursor_file_falls_back_to_seeding(self, adapter, tmp_path):
|
|
path = self._cursor_file(tmp_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("{ not json", encoding="utf-8")
|
|
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [_event("e1", created_at=100)])
|
|
adapter._run_cli = cli
|
|
adapter._load_cursors()
|
|
await adapter._seed_channel(CHANNEL, chat_type="group")
|
|
|
|
# Degrades to the old behaviour rather than failing the connect.
|
|
assert adapter._restored_cursors == {}
|
|
assert set(adapter._channel_state[CHANNEL]["seen"]) == {"e1"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restored_seen_set_stays_bounded(self, adapter, tmp_path):
|
|
cap = _buzz_mod._SEEN_CAP
|
|
path = self._cursor_file(tmp_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps({
|
|
"identity": SELF_PUBKEY,
|
|
"relay": "https://test.relay",
|
|
"channels": {
|
|
CHANNEL: {
|
|
"chat_type": "group",
|
|
"last_ts": 100,
|
|
"seen": [f"e{i}" for i in range(cap * 2)],
|
|
}
|
|
},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
adapter._load_cursors()
|
|
await adapter._seed_channel(CHANNEL, chat_type="group")
|
|
|
|
seen = adapter._channel_state[CHANNEL]["seen"]
|
|
assert len(seen) == cap
|
|
# The newest ids are the ones worth keeping for de-dupe.
|
|
assert f"e{cap * 2 - 1}" in seen
|
|
assert "e0" not in seen
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idle_poll_does_not_rewrite_the_cursor(self, adapter, tmp_path):
|
|
cli = await self._seed(adapter, _event("e1", created_at=100))
|
|
path = self._cursor_file(tmp_path)
|
|
before = path.stat().st_mtime_ns
|
|
|
|
cli.responses.clear()
|
|
cli.script("messages", "get", [_event("e1", created_at=100)])
|
|
await adapter._poll_channel(CHANNEL)
|
|
|
|
assert path.stat().st_mtime_ns == before
|