Files
aiturk-hermes-ide/tests/gateway/test_slack_turn_recipient_identity.py

85 lines
3.5 KiB
Python

"""Per-turn Slack egress identity (R3-5, connector PR gateway-gateway#210).
The relay connector fills chat.startStream's recipient_user_id /
recipient_team_id (required for channel streams) from metadata.user_id /
metadata.scope_id. Before this fix the gateway only stamped slack_team_id
per-turn; user_id (and scope_id) were left for the relay adapter's
_with_scope fallback, which reads per-chat caches keyed only by chat_id —
mutable state that a CONCURRENT turn overwrites. Two users with
overlapping turns in one channel could open U1's stream with U2 as the
recipient.
These tests pin the fix: _thread_metadata_for_source stamps the authentic
per-turn identity from the turn's OWN source, and the cache fallback in
RelayAdapter._with_scope no longer decides for turns that carry it.
"""
from types import SimpleNamespace
from gateway.platforms.base import Platform
from gateway.run import GatewayRunner
def _slack_source(user_id: str, chat_id: str = "C_SHARED") -> SimpleNamespace:
return SimpleNamespace(
platform=Platform.SLACK,
chat_id=chat_id,
thread_id="1700.100",
chat_type="channel",
message_id="1700.100",
scope_id="T_TEAM",
user_id=user_id,
)
def test_thread_metadata_stamps_per_turn_user_and_scope():
runner = object.__new__(GatewayRunner)
meta = runner._thread_metadata_for_source(_slack_source("U_ALICE"))
assert meta is not None
assert meta["user_id"] == "U_ALICE"
assert meta["scope_id"] == "T_TEAM"
assert meta["slack_team_id"] == "T_TEAM"
def test_concurrent_turns_carry_their_own_identity():
"""The R3-5 interleaving: U2's turn starting must not change what U1's
already-built metadata carries — identity is per-turn source data, not
shared per-chat state."""
runner = object.__new__(GatewayRunner)
meta_alice = runner._thread_metadata_for_source(_slack_source("U_ALICE"))
# U2's turn begins in the SAME channel before U1's stream opens.
meta_bob = runner._thread_metadata_for_source(_slack_source("U_BOB"))
assert meta_alice is not None and meta_bob is not None
assert meta_alice["user_id"] == "U_ALICE"
assert meta_bob["user_id"] == "U_BOB"
def test_relay_with_scope_does_not_override_per_turn_identity():
"""_with_scope must be fill-only: a turn that already carries user_id /
scope_id keeps them even when the per-chat cache holds another user
(the overwrite that caused the R3-5 cross-recipient hazard)."""
from gateway.relay.adapter import RelayAdapter
adapter = object.__new__(RelayAdapter)
# The mutable cache holds the OTHER user (their turn arrived later).
adapter._scope_by_chat = {"C_SHARED": "T_CACHED"}
adapter._dm_user_by_chat = {"C_SHARED": "U_BOB"}
merged = adapter._with_scope(
"C_SHARED", {"user_id": "U_ALICE", "scope_id": "T_TEAM"}
)
assert merged["user_id"] == "U_ALICE"
assert merged["scope_id"] == "T_TEAM"
def test_relay_with_scope_still_fills_when_turn_carries_nothing():
"""Restart/synthetic sends (no per-turn identity) keep the cache
fallback — the fix narrows the cache's authority, not its existence."""
from gateway.relay.adapter import RelayAdapter
adapter = object.__new__(RelayAdapter)
adapter._scope_by_chat = {"C_SHARED": "T_CACHED"}
adapter._dm_user_by_chat = {"C_SHARED": "U_CACHED"}
merged = adapter._with_scope("C_SHARED", {"thread_id": "1700.100"})
assert merged["user_id"] == "U_CACHED"
assert merged["scope_id"] == "T_CACHED"