430 lines
18 KiB
Python
430 lines
18 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ChatterMate
|
||
|
|
|
||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
you may not use this file except in compliance with the License.
|
||
|
|
You may obtain a copy of the License at
|
||
|
|
|
||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
|
||
|
|
Unless required by applicable law or agreed to in writing, software
|
||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
See the License for the specific language governing permissions and
|
||
|
|
limitations under the License.
|
||
|
|
|
||
|
|
A widget visitor can be bound to an existing customer by an email they only
|
||
|
|
typed. That keeps one person in the inbox, but must never hand them that
|
||
|
|
person's conversations: a widget-issued token reaches only the conversations
|
||
|
|
started from its own browser, and may not stand for a person the embedding app
|
||
|
|
has signed in.
|
||
|
|
"""
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi import FastAPI
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from socketio.exceptions import ConnectionRefusedError
|
||
|
|
|
||
|
|
from app.api import token as token_router
|
||
|
|
from app.api import widget as widget_router
|
||
|
|
from app.api import widget_chat
|
||
|
|
from app.core.security import create_conversation_token
|
||
|
|
from app.database import get_db
|
||
|
|
from app.models.customer import Customer
|
||
|
|
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||
|
|
from app.repositories.customer import CustomerRepository
|
||
|
|
from app.repositories.session_to_agent import SessionToAgentRepository
|
||
|
|
from app.services import conversation_token
|
||
|
|
from app.services.contact_capture import CONTACT_EMAIL_META_KEY
|
||
|
|
|
||
|
|
VICTIM_BROWSER = "victim-browser"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def client(db) -> TestClient:
|
||
|
|
app = FastAPI()
|
||
|
|
app.include_router(widget_router.router, prefix="/api/v1/widgets")
|
||
|
|
app.include_router(token_router.router, prefix="/api/v1")
|
||
|
|
|
||
|
|
def override_get_db():
|
||
|
|
yield db
|
||
|
|
|
||
|
|
app.dependency_overrides[get_db] = override_get_db
|
||
|
|
return TestClient(app)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_sio():
|
||
|
|
mock = MagicMock()
|
||
|
|
mock.enter_room = AsyncMock()
|
||
|
|
mock.emit = AsyncMock()
|
||
|
|
mock.save_session = AsyncMock()
|
||
|
|
mock.get_session = AsyncMock()
|
||
|
|
return mock
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def socket(db, test_widget, test_ai_config, mock_sio, monkeypatch):
|
||
|
|
"""Connect a widget socket with a given token; returns its saved session."""
|
||
|
|
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||
|
|
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"app.repositories.widget.WidgetRepository.get_widget",
|
||
|
|
lambda self, widget_id: test_widget,
|
||
|
|
)
|
||
|
|
ai_repo = MagicMock()
|
||
|
|
ai_repo.get_active_config.return_value = test_ai_config
|
||
|
|
monkeypatch.setattr(widget_chat, "AIConfigRepository", lambda db: ai_repo)
|
||
|
|
|
||
|
|
async def connect(token: str, sid: str):
|
||
|
|
claims = conversation_token.claims_ignoring_expiry(token)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
widget_chat,
|
||
|
|
"authenticate_socket_conversation_token",
|
||
|
|
AsyncMock(return_value=(
|
||
|
|
str(test_widget.id), str(test_widget.organization_id), claims.get("sub"), token,
|
||
|
|
)),
|
||
|
|
)
|
||
|
|
mock_sio.save_session.reset_mock()
|
||
|
|
if not await widget_chat.widget_connect(sid, {}, {}):
|
||
|
|
return None
|
||
|
|
return mock_sio.save_session.call_args[0][1]
|
||
|
|
|
||
|
|
return connect
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def victim_session(db, test_widget, test_customer, test_user) -> SessionToAgent:
|
||
|
|
"""The victim's live conversation, with a human agent on it."""
|
||
|
|
session = SessionToAgent(
|
||
|
|
session_id=uuid4(),
|
||
|
|
customer_id=test_customer.id,
|
||
|
|
agent_id=test_widget.agent_id,
|
||
|
|
organization_id=test_widget.organization_id,
|
||
|
|
user_id=test_user.id,
|
||
|
|
status=SessionStatus.OPEN,
|
||
|
|
conversation_key=VICTIM_BROWSER,
|
||
|
|
)
|
||
|
|
db.add(session)
|
||
|
|
db.commit()
|
||
|
|
return session
|
||
|
|
|
||
|
|
|
||
|
|
def _victim_token(test_widget, test_customer) -> str:
|
||
|
|
return create_conversation_token(
|
||
|
|
widget_id=str(test_widget.id), customer_id=str(test_customer.id), ckey=VICTIM_BROWSER
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _customer_of(data: dict) -> str:
|
||
|
|
"""The customer a GET /widgets response bound the visitor to."""
|
||
|
|
return conversation_token.claims_ignoring_expiry(data["token"])["sub"]
|
||
|
|
|
||
|
|
|
||
|
|
def _emitted(mock_sio, event: str) -> dict:
|
||
|
|
for call in reversed(mock_sio.emit.call_args_list):
|
||
|
|
if call[0][0] == event:
|
||
|
|
return call[0][1]
|
||
|
|
raise AssertionError(f"{event} was never emitted")
|
||
|
|
|
||
|
|
|
||
|
|
class TestEmailDoesNotOpenAnotherBrowsersConversation:
|
||
|
|
"""GHSA-rfh4-6ghv-m2rr: GET /widgets/{id}?email=<victim>."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_the_reported_takeover_lands_in_a_new_empty_conversation(
|
||
|
|
self, client, socket, mock_sio, test_widget, test_customer, victim_session
|
||
|
|
):
|
||
|
|
response = client.get(f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email})
|
||
|
|
assert response.status_code == 200, response.text
|
||
|
|
data = response.json()
|
||
|
|
|
||
|
|
# Still one person in the inbox...
|
||
|
|
assert _customer_of(data) == str(test_customer.id)
|
||
|
|
# ...but nothing of theirs: not even who is helping them.
|
||
|
|
assert not (data["human_agent"] or {}).get("human_agent_name")
|
||
|
|
claims = conversation_token.inspect(data["token"], str(test_widget.id)).payload
|
||
|
|
assert claims["ckey"] != VICTIM_BROWSER
|
||
|
|
|
||
|
|
attacker = await socket(data["token"], "attacker")
|
||
|
|
assert attacker["session_id"] != str(victim_session.session_id)
|
||
|
|
assert _emitted(mock_sio, "session_initialized")["created"] is True
|
||
|
|
joined = [call[0][1] for call in mock_sio.enter_room.call_args_list]
|
||
|
|
assert str(victim_session.session_id) not in joined
|
||
|
|
|
||
|
|
mock_sio.get_session.return_value = attacker
|
||
|
|
await widget_chat.get_widget_chat_history("attacker")
|
||
|
|
assert _emitted(mock_sio, "chat_history")["messages"] == []
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_the_victim_still_lands_in_their_own_conversation(
|
||
|
|
self, client, socket, test_widget, test_customer, victim_session
|
||
|
|
):
|
||
|
|
attacker_token = client.get(
|
||
|
|
f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}
|
||
|
|
).json()["token"]
|
||
|
|
await socket(attacker_token, "attacker")
|
||
|
|
|
||
|
|
victim = await socket(_victim_token(test_widget, test_customer), "victim")
|
||
|
|
|
||
|
|
assert victim["session_id"] == str(victim_session.session_id)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_token_issued_before_conversation_keys_reaches_no_existing_conversation(
|
||
|
|
self, db, socket, test_widget, test_customer
|
||
|
|
):
|
||
|
|
keyless = SessionToAgent(
|
||
|
|
session_id=uuid4(), customer_id=test_customer.id, agent_id=test_widget.agent_id,
|
||
|
|
organization_id=test_widget.organization_id, status=SessionStatus.OPEN,
|
||
|
|
)
|
||
|
|
db.add(keyless)
|
||
|
|
db.commit()
|
||
|
|
legacy_token = create_conversation_token(
|
||
|
|
widget_id=str(test_widget.id), customer_id=str(test_customer.id)
|
||
|
|
)
|
||
|
|
|
||
|
|
connected = await socket(legacy_token, "legacy")
|
||
|
|
|
||
|
|
assert connected["session_id"] != str(keyless.session_id)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_an_unverifiable_token_is_refused(self, socket, mock_sio, test_widget, test_customer, monkeypatch):
|
||
|
|
monkeypatch.setattr(widget_chat, "verify_conversation_token", lambda token: None)
|
||
|
|
|
||
|
|
assert await socket(_victim_token(test_widget, test_customer), "sid") is None
|
||
|
|
assert _emitted(mock_sio, "error")["type"] == "connection_error"
|
||
|
|
|
||
|
|
|
||
|
|
class TestOwnConversationOnly:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_closed_conversation_reopens_despite_a_newer_one_from_another_browser(
|
||
|
|
self, db, socket, mock_sio, test_widget, test_customer, victim_session, monkeypatch
|
||
|
|
):
|
||
|
|
victim = await socket(_victim_token(test_widget, test_customer), "victim")
|
||
|
|
victim_session.status = SessionStatus.CLOSED
|
||
|
|
victim_session.user_id = None
|
||
|
|
victim_session.assigned_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||
|
|
db.add(SessionToAgent(
|
||
|
|
session_id=uuid4(), customer_id=test_customer.id, agent_id=test_widget.agent_id,
|
||
|
|
organization_id=test_widget.organization_id, status=SessionStatus.OPEN,
|
||
|
|
conversation_key="someone-else", assigned_at=datetime.now(timezone.utc),
|
||
|
|
))
|
||
|
|
db.commit()
|
||
|
|
monkeypatch.setattr(widget_chat, "notify_new_chat", AsyncMock())
|
||
|
|
monkeypatch.setattr(
|
||
|
|
widget_chat.ChatAgent, "create_async", AsyncMock(side_effect=RuntimeError("stop here"))
|
||
|
|
)
|
||
|
|
|
||
|
|
mock_sio.get_session.return_value = victim
|
||
|
|
await widget_chat.handle_widget_chat("victim", {"message": "still there?"})
|
||
|
|
|
||
|
|
db.refresh(victim_session)
|
||
|
|
assert victim_session.status == SessionStatus.OPEN
|
||
|
|
|
||
|
|
def test_there_is_no_lookup_of_a_conversation_by_customer_alone(self):
|
||
|
|
assert not hasattr(SessionToAgentRepository, "get_active_customer_session")
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_claimed_email_cannot_rename_the_customer(
|
||
|
|
self, db, client, socket, mock_sio, test_widget, test_customer, victim_session
|
||
|
|
):
|
||
|
|
test_customer.full_name = "Real Name"
|
||
|
|
db.commit()
|
||
|
|
attacker_token = client.get(
|
||
|
|
f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}
|
||
|
|
).json()["token"]
|
||
|
|
attacker = await socket(attacker_token, "attacker")
|
||
|
|
|
||
|
|
mock_sio.get_session.return_value = attacker
|
||
|
|
await widget_chat.handle_contact_info("attacker", {"form_data": {"name": "Attacker"}})
|
||
|
|
|
||
|
|
db.refresh(test_customer)
|
||
|
|
assert test_customer.full_name == "Real Name"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_claimed_email_cannot_leave_a_follow_up_address(
|
||
|
|
self, db, client, socket, mock_sio, test_widget, test_customer, victim_session
|
||
|
|
):
|
||
|
|
attacker_token = client.get(
|
||
|
|
f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}
|
||
|
|
).json()["token"]
|
||
|
|
attacker = await socket(attacker_token, "attacker")
|
||
|
|
|
||
|
|
mock_sio.get_session.return_value = attacker
|
||
|
|
await widget_chat.handle_contact_info("attacker", {"form_data": {"email": "attacker@evil.com"}})
|
||
|
|
|
||
|
|
db.refresh(test_customer)
|
||
|
|
assert CONTACT_EMAIL_META_KEY not in (test_customer.meta_data or {})
|
||
|
|
|
||
|
|
|
||
|
|
class TestMergedToken:
|
||
|
|
"""A device token naming a customer that lead capture merged into another."""
|
||
|
|
|
||
|
|
def test_a_token_for_a_merged_customer_is_judged_on_the_survivor(
|
||
|
|
self, db, client, test_widget, test_organization
|
||
|
|
):
|
||
|
|
survivor = Customer(email="member@acme.com", organization_id=test_organization.id,
|
||
|
|
is_authenticated=True)
|
||
|
|
db.add(survivor); db.commit(); db.refresh(survivor)
|
||
|
|
merged = Customer(email="123@noemail.com", organization_id=test_organization.id,
|
||
|
|
conversation_key="browser-a", merged_into_customer_id=survivor.id)
|
||
|
|
db.add(merged); db.commit(); db.refresh(merged)
|
||
|
|
token = create_conversation_token(
|
||
|
|
widget_id=str(test_widget.id), customer_id=str(merged.id), ckey="browser-a")
|
||
|
|
|
||
|
|
data = client.get(f"/api/v1/widgets/{test_widget.id}",
|
||
|
|
headers={"Authorization": f"Bearer {token}"}).json()
|
||
|
|
|
||
|
|
# Swapped now, rather than accepted here and refused on every connect.
|
||
|
|
assert data["token"]
|
||
|
|
assert _customer_of(data) not in (str(survivor.id), str(merged.id))
|
||
|
|
|
||
|
|
|
||
|
|
class TestPreClaimedAddress:
|
||
|
|
"""An address claimed through the widget before its owner signs in."""
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def pre_claim(self, db, client, test_widget, test_organization):
|
||
|
|
"""The attacker claims new@x; later the app signs the real user in."""
|
||
|
|
data = client.get(
|
||
|
|
f"/api/v1/widgets/{test_widget.id}", params={"email": "new.user@example.com"}
|
||
|
|
).json()
|
||
|
|
claimed = CustomerRepository(db).get_by_id(_customer_of(data))
|
||
|
|
assert claimed.email == "new.user@example.com"
|
||
|
|
|
||
|
|
signed_in = CustomerRepository(db).identify("new.user@example.com", test_organization.id)
|
||
|
|
assert signed_in.id == claimed.id and signed_in.is_authenticated
|
||
|
|
users_chat = SessionToAgent(
|
||
|
|
session_id=uuid4(), customer_id=signed_in.id, agent_id=test_widget.agent_id,
|
||
|
|
organization_id=test_widget.organization_id, status=SessionStatus.OPEN,
|
||
|
|
)
|
||
|
|
db.add(users_chat)
|
||
|
|
db.commit()
|
||
|
|
return data["token"], signed_in, users_chat
|
||
|
|
|
||
|
|
def test_the_old_token_is_swapped_for_a_customer_of_its_own(self, db, client, test_widget, pre_claim):
|
||
|
|
attacker_token, signed_in, _ = pre_claim
|
||
|
|
|
||
|
|
response = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {attacker_token}"})
|
||
|
|
|
||
|
|
assert response.status_code == 200, response.text
|
||
|
|
data = response.json()
|
||
|
|
assert data["token"]
|
||
|
|
assert _customer_of(data) != str(signed_in.id)
|
||
|
|
swapped = CustomerRepository(db).get_by_id(_customer_of(data))
|
||
|
|
assert swapped.meta_data[CONTACT_EMAIL_META_KEY] == "new.user@example.com"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_the_old_token_is_sent_back_to_authorize(self, socket, pre_claim):
|
||
|
|
"""Refused the way an unbound token is, so the widget authorizes again
|
||
|
|
and GET /widgets/{id} hands it a customer of its own."""
|
||
|
|
attacker_token, _, _ = pre_claim
|
||
|
|
|
||
|
|
with pytest.raises(ConnectionRefusedError) as refused:
|
||
|
|
await socket(attacker_token, "attacker")
|
||
|
|
|
||
|
|
assert refused.value.error_args["data"]["type"] == "customer_required"
|
||
|
|
|
||
|
|
def test_typing_the_address_again_gets_an_anonymous_customer(self, db, client, test_widget, pre_claim):
|
||
|
|
_, signed_in, _ = pre_claim
|
||
|
|
|
||
|
|
data = client.get(
|
||
|
|
f"/api/v1/widgets/{test_widget.id}", params={"email": "new.user@example.com"}
|
||
|
|
).json()
|
||
|
|
|
||
|
|
assert _customer_of(data) != str(signed_in.id)
|
||
|
|
fresh = CustomerRepository(db).get_by_id(_customer_of(data))
|
||
|
|
assert CustomerRepository.is_placeholder_email(fresh.email)
|
||
|
|
assert fresh.meta_data[CONTACT_EMAIL_META_KEY] == "new.user@example.com"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_the_signed_in_user_keeps_their_conversation(self, socket, test_widget, pre_claim):
|
||
|
|
_, signed_in, users_chat = pre_claim
|
||
|
|
verified_token, _, _ = conversation_token.mint({
|
||
|
|
"sub": str(signed_in.id),
|
||
|
|
"widget_id": str(test_widget.id),
|
||
|
|
"customer_email": signed_in.email,
|
||
|
|
}, ttl_seconds=600)
|
||
|
|
|
||
|
|
connected = await socket(verified_token, "user")
|
||
|
|
|
||
|
|
assert connected["session_id"] == str(users_chat.session_id)
|
||
|
|
|
||
|
|
|
||
|
|
def _mint(widget, customer, **claims) -> str:
|
||
|
|
token, _, _ = conversation_token.mint(
|
||
|
|
{"sub": str(customer.id), "widget_id": str(widget.id), **claims}, ttl_seconds=600)
|
||
|
|
return token
|
||
|
|
|
||
|
|
|
||
|
|
class TestAccountScope:
|
||
|
|
"""Our in-app support chat: a token our backend vouched for on behalf of one
|
||
|
|
dashboard account, keyed to that account rather than to the address."""
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def signed_in(self, db, test_organization) -> Customer:
|
||
|
|
customer = Customer(email="member@acme.com", organization_id=test_organization.id, is_authenticated=True)
|
||
|
|
db.add(customer)
|
||
|
|
db.commit()
|
||
|
|
return customer
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_the_account_keeps_its_customer_and_its_conversation(self, client, socket, test_widget, signed_in):
|
||
|
|
key = conversation_token.account_conversation_key("account-1")
|
||
|
|
token = _mint(test_widget, signed_in, customer_email=signed_in.email, ckey=key)
|
||
|
|
|
||
|
|
data = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {token}"}).json()
|
||
|
|
assert "token" not in data or data["token"] is None # no swap to an anonymous customer
|
||
|
|
|
||
|
|
first = await socket(token, "drawer-1")
|
||
|
|
again = await socket(token, "drawer-2")
|
||
|
|
assert again["session_id"] == first["session_id"]
|
||
|
|
|
||
|
|
def test_a_widget_token_never_carries_an_account_key_forward(self, client, test_widget, signed_in):
|
||
|
|
"""An unverified token claiming an account key is refused, and the widget's
|
||
|
|
replacement starts a key of its own."""
|
||
|
|
forged = create_conversation_token(widget_id=str(test_widget.id),
|
||
|
|
ckey=conversation_token.account_conversation_key("account-1"))
|
||
|
|
|
||
|
|
data = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {forged}"}).json()
|
||
|
|
|
||
|
|
claims = conversation_token.claims_ignoring_expiry(data["token"])
|
||
|
|
assert not conversation_token.is_account_key(claims["ckey"])
|
||
|
|
|
||
|
|
|
||
|
|
class TestEdges:
|
||
|
|
def test_a_token_for_a_customer_that_is_gone_gets_a_new_one(self, client, test_widget):
|
||
|
|
"""Answering without a token here meant every connect was refused, forever."""
|
||
|
|
ghost = create_conversation_token(widget_id=str(test_widget.id), customer_id=str(uuid4()), ckey="browser-a")
|
||
|
|
|
||
|
|
data = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {ghost}"}).json()
|
||
|
|
|
||
|
|
assert data["token"]
|
||
|
|
assert conversation_token.claims_ignoring_expiry(data["token"])["ckey"] == "browser-a"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_refresh_cannot_move_the_socket_to_another_scope(self, socket, mock_sio, test_widget, test_customer):
|
||
|
|
connected = await socket(_victim_token(test_widget, test_customer), "victim")
|
||
|
|
other_scope = create_conversation_token(widget_id=str(test_widget.id), customer_id=str(test_customer.id),
|
||
|
|
ckey="another-browser")
|
||
|
|
mock_sio.get_session.return_value = dict(connected)
|
||
|
|
mock_sio.save_session.reset_mock()
|
||
|
|
|
||
|
|
await widget_chat.handle_refresh_token("victim", {"conversation_token": other_scope})
|
||
|
|
|
||
|
|
mock_sio.save_session.assert_not_awaited()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_visitor_who_cannot_save_contact_details_still_hears_back(
|
||
|
|
self, db, client, socket, mock_sio, test_widget, test_customer, victim_session
|
||
|
|
):
|
||
|
|
stranger = client.get(f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}).json()["token"]
|
||
|
|
mock_sio.get_session.return_value = await socket(stranger, "stranger")
|
||
|
|
mock_sio.emit.reset_mock()
|
||
|
|
|
||
|
|
await widget_chat.handle_contact_info("stranger", {"form_data": {"name": "Someone"}})
|
||
|
|
|
||
|
|
assert _emitted(mock_sio, "chat_response")["message"] == "Thanks — a teammate will follow up here."
|