Files
openworker/tests/test_slack_relay.py
T

425 lines
13 KiB
Python

"""Managed Slack relay client (Milestone 3) — dual-mode desktop, team-qualified
addressing, per-team reply tokens. Hermetic: an injected fake relay transport
(no live WebSocket) + captured senders (no network)."""
from __future__ import annotations
import asyncio
import pytest
from coworker.connectors import relay_client
from coworker.connectors.adapters import make_adapter
from coworker.connectors.base import InteractionEvent, MessageEvent
from coworker.connectors.config import ConnectorSettings, load_settings
from coworker.connectors.relay_client import SlackRelayAdapter
from coworker.connectors.slack_addr import qualify, split
from coworker.connectors.tools import make_send_message_tool
from coworker.secrets import SecretStore
@pytest.fixture(autouse=True)
def _no_slack_network(monkeypatch):
"""Name/channel resolution is best-effort; unstubbed lookups must fail
instantly at a dead loopback port, never reach slack.com — a slow real
answer was blowing the 2s wait_dispatched window intermittently."""
monkeypatch.setenv("SLACK_API_URL", "http://127.0.0.1:9/")
TEAMS = {
"T1": {"bot_token": "xoxb-team1", "bot_user_id": "UBOT1"},
"T2": {"bot_token": "xoxb-team2", "bot_user_id": "UBOT2"},
}
class FakeTransport:
"""Yields queued frames, then either closes (recv→None) or blocks forever."""
def __init__(self, frames, *, close_after: bool):
self._q: asyncio.Queue = asyncio.Queue()
for f in frames:
self._q.put_nowait(f)
self._close_after = close_after
self.opened = False
async def open(self):
self.opened = True
async def recv(self):
if not self._q.empty():
return self._q.get_nowait()
if self._close_after:
return None
await asyncio.Event().wait() # block until cancelled
async def close(self):
pass
def _factory(*transports):
"""A transport_factory that hands out the given transports in order."""
it = iter(transports)
def make():
return next(it)
return make
def _event_frame(team, channel, user, text="hi", ts="1.0"):
return {
"provider": "slack",
"team_id": team,
"address": f"slack:{team}:{channel}",
"channel": channel,
"event_id": f"Ev-{ts}",
"event": {
"type": "app_mention",
"user": user,
"channel": channel,
"text": text,
"ts": ts,
},
}
def _adapter(frames, *, close_after=False, **kw) -> SlackRelayAdapter:
return SlackRelayAdapter(
"wss://relay.test/ws",
token_provider=lambda: "jwt-token",
teams=dict(TEAMS),
transport_factory=_factory(FakeTransport(frames, close_after=close_after)),
reconnect_delay=0.0,
**kw,
)
def _collect(adapter):
events: list[MessageEvent] = []
adapter.set_message_handler(lambda e: events.append(e) or asyncio.sleep(0))
return events
# --- pure addressing --------------------------------------------------------
def test_slack_addr_roundtrip():
assert qualify("T1", "C9") == "T1/C9"
assert qualify(None, "C9") == "C9"
assert split("T1/C9") == ("T1", "C9")
assert split("C9") == (None, "C9")
# --- inbound dispatch -------------------------------------------------------
async def test_relay_dispatches_team_qualified_event():
adapter = _adapter([_event_frame("T1", "C1", "U_ALICE")])
events: list[MessageEvent] = []
async def handler(e):
events.append(e)
adapter.set_message_handler(handler)
assert await adapter.connect() is True
try:
await adapter.wait_dispatched(1)
finally:
await adapter.disconnect()
assert len(events) == 1
ev = events[0]
assert ev.source.chat_id == "T1/C1"
assert ev.source.target == "slack:T1/C1" # team-qualified reply handle
assert ev.source.team_id == "T1"
assert ev.text == "hi"
async def test_relay_resolves_names_and_mentions(monkeypatch):
adapter = _adapter([_event_frame("T1", "C1", "U_ALICE", text="<@UBOT1> hey bot")])
async def fake_get(team_id, method, params):
assert team_id == "T1" # resolved with THIS workspace's token
if method == "users.info":
names = {
"U_ALICE": {"profile": {"display_name": "Rohit"}},
"UBOT1": {"profile": {"display_name": "ocw"}},
}
return {"ok": True, "user": names.get(params["user"], {})}
if method == "conversations.info":
return {"ok": True, "channel": {"name": "ocw-test"}}
return None
monkeypatch.setattr(adapter, "_slack_get", fake_get)
events: list[MessageEvent] = []
async def handler(e):
events.append(e)
adapter.set_message_handler(handler)
await adapter.connect()
try:
await adapter.wait_dispatched(1)
finally:
await adapter.disconnect()
ev = events[0]
assert ev.source.user_name == "Rohit" # not U_ALICE
assert ev.source.chat_name == "ocw-test" # not C1
assert ev.text == "@ocw hey bot" # <@UBOT1> rewritten
async def test_relay_name_cache_is_per_workspace(monkeypatch):
"""A cached (team, id) must not leak across workspaces."""
adapter = SlackRelayAdapter("wss://x", lambda: "jwt", teams=dict(TEAMS))
calls = []
async def fake_get(team_id, method, params):
calls.append((team_id, params.get("user")))
return {
"ok": True,
"user": {"profile": {"display_name": f"{team_id}:{params['user']}"}},
}
monkeypatch.setattr(adapter, "_slack_get", fake_get)
# Same uid string in two workspaces resolves independently + caches per team.
assert await adapter._display_name("T1", "U9") == "T1:U9"
assert await adapter._display_name("T2", "U9") == "T2:U9"
assert await adapter._display_name("T1", "U9") == "T1:U9" # cached, no new call
assert calls == [("T1", "U9"), ("T2", "U9")] # T1 second lookup served from cache
async def test_relay_two_workspace_fan_in():
adapter = _adapter(
[
_event_frame("T1", "C1", "U_A", ts="1"),
_event_frame("T2", "C2", "U_B", ts="2"),
]
)
events: list[MessageEvent] = []
async def handler(e):
events.append(e)
adapter.set_message_handler(handler)
await adapter.connect()
try:
await adapter.wait_dispatched(2)
finally:
await adapter.disconnect()
chats = {e.source.chat_id for e in events}
assert chats == {"T1/C1", "T2/C2"}
async def test_relay_ignores_own_bot_echo():
# event.user == the team's bot user id → dropped by the mapper.
adapter = _adapter([_event_frame("T1", "C1", "UBOT1")])
events: list = []
adapter.set_message_handler(lambda e: events.append(e))
await adapter.connect()
await asyncio.sleep(0.05)
await adapter.disconnect()
assert events == []
# --- reconnect --------------------------------------------------------------
async def test_relay_watchdog_reconnects():
t1 = FakeTransport([_event_frame("T1", "C1", "U_A", ts="1")], close_after=True)
t2 = FakeTransport([_event_frame("T1", "C1", "U_A", ts="2")], close_after=False)
adapter = SlackRelayAdapter(
"wss://relay.test/ws",
token_provider=lambda: "jwt",
teams=dict(TEAMS),
transport_factory=_factory(t1, t2),
reconnect_delay=0.0,
)
events: list = []
async def handler(e):
events.append(e)
adapter.set_message_handler(handler)
await adapter.connect()
try:
await adapter.wait_dispatched(2)
finally:
await adapter.disconnect()
assert adapter.reconnects == 1 # dropped socket self-healed
assert t2.opened is True
assert len(events) == 2
# --- interactivity / revoked / nudge ---------------------------------------
async def test_relay_interactivity_maps_to_interaction():
frame = {
"kind": "interactivity",
"team_id": "T2",
"interaction": {
"user": {"id": "U2", "username": "bob"},
"channel": {"id": "C7"},
"message": {"ts": "9.9"},
"response_url": "https://hooks.slack.com/actions/abc",
"actions": [{"value": "approve:42"}],
},
}
adapter = _adapter([frame])
seen: list[InteractionEvent] = []
async def on_interaction(e):
seen.append(e)
adapter.set_interaction_handler(on_interaction)
await adapter.connect()
try:
await adapter.wait_dispatched(1)
finally:
await adapter.disconnect()
assert len(seen) == 1
assert seen[0].chat_id == "T2/C7" and seen[0].value == "approve:42"
assert seen[0].user_id == "U2"
assert seen[0].team_id == "T2"
assert seen[0].response_url == "https://hooks.slack.com/actions/abc"
async def test_relay_revoked_drops_team():
adapter = _adapter([{"kind": "revoked", "team_id": "T1"}])
await adapter.connect()
try:
await adapter.wait_dispatched(1)
finally:
await adapter.disconnect()
assert "T1" not in adapter._teams and "T2" in adapter._teams
async def test_relay_nudge_pulls_history():
fetched = {}
async def fetcher(team, channel, count):
fetched.update(team=team, channel=channel, count=count)
return [
{"type": "message", "user": "U_A", "text": "missed one", "ts": "1"},
{"type": "message", "user": "U_A", "text": "missed two", "ts": "2"},
]
adapter = _adapter(
[{"kind": "missed", "team_id": "T1", "channel": "C1", "count": 2}],
history_fetcher=fetcher,
)
events: list[MessageEvent] = []
async def handler(e):
events.append(e)
adapter.set_message_handler(handler)
await adapter.connect()
try:
await adapter.wait_dispatched(1)
finally:
await adapter.disconnect()
assert fetched == {"team": "T1", "channel": "C1", "count": 2}
assert [e.text for e in events] == ["missed one", "missed two"]
assert all(e.source.chat_id == "T1/C1" for e in events)
# --- outbound per-team token ------------------------------------------------
async def test_relay_send_selects_per_team_token(monkeypatch):
captured = {}
def fake_send(token, chat_id, text, thread_id=None):
captured.update(token=token, chat_id=chat_id, text=text)
from coworker.connectors.base import SendResult
return SendResult(True, message_id="ts1")
monkeypatch.setattr(relay_client, "_send_slack", fake_send)
adapter = SlackRelayAdapter("wss://x", lambda: "jwt", teams=dict(TEAMS))
res = await adapter.send("T2/C9", "hello")
assert res.ok
assert captured["token"] == "xoxb-team2" # T2's token, not T1's
assert captured["chat_id"] == "T2/C9"
# --- send_message tool per-team selection -----------------------------------
def test_send_message_tool_per_team_and_default_token():
secrets = SecretStore()
secrets.put("slack:team:T1", {"bot_token": "xoxb-team1"})
secrets.put("slack:default", {"bot_token": "xoxb-manual", "mode": "socket"})
calls = []
def fake_slack(token, chat_id, text, thread_id):
calls.append((token, chat_id))
from coworker.connectors.base import SendResult
return SendResult(True, message_id="ts")
tool = make_send_message_tool(secrets, senders={"slack": fake_slack})
# Team-qualified → per-team token
tool("slack:T1/C1", "hi")
# Bare (manual socket mode) → default token
tool("slack:Cbare", "hi")
assert calls[0][0] == "xoxb-team1"
assert calls[1][0] == "xoxb-manual"
# --- dual-mode adapter selection -------------------------------------------
def test_make_adapter_relay_mode_builds_relay_client():
adapter = make_adapter(
"slack",
{"mode": "relay", "enabled": True},
secrets=SecretStore(),
token_provider=lambda: "jwt",
relay_url="wss://relay.test/ws",
)
assert isinstance(adapter, SlackRelayAdapter)
def test_make_adapter_socket_mode_builds_socket_adapter():
from coworker.connectors.adapters import SlackAdapter
adapter = make_adapter("slack", {"bot_token": "xoxb", "app_token": "xapp"})
assert isinstance(adapter, SlackAdapter)
def test_make_adapter_relay_without_endpoint_returns_none():
# Relay mode configured but no relay_url / sign-in → don't build (falls back).
adapter = make_adapter(
"slack", {"mode": "relay", "enabled": True}, secrets=SecretStore()
)
assert adapter is None
def test_relay_mode_enables_slack_without_bot_token():
secrets = SecretStore()
secrets.put("slack:default", {"mode": "relay", "enabled": True})
settings = load_settings(secrets)
assert settings["slack"].enabled is True
def test_make_adapter_loads_per_team_tokens():
secrets = SecretStore()
secrets.put("slack:team:T9", {"bot_token": "xoxb-9", "bot_user_id": "U9"})
adapter = make_adapter(
"slack",
{"mode": "relay", "enabled": True},
secrets=secrets,
token_provider=lambda: "jwt",
relay_url="wss://relay/ws",
)
assert adapter._bot_token("T9") == "xoxb-9"