85 lines
3.5 KiB
Python
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"
|