402 lines
15 KiB
Python
402 lines
15 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ReplyPier
|
||
|
|
|
||
|
|
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.
|
||
|
|
|
||
|
|
What the widget socket tells the page about the conversation it landed in, and
|
||
|
|
adopting a rotated token on a connection that is already open.
|
||
|
|
"""
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.api import widget_chat
|
||
|
|
from app.models.customer import Customer
|
||
|
|
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||
|
|
from app.repositories.session_to_agent import SessionToAgentRepository
|
||
|
|
from app.services import conversation_token
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_sio():
|
||
|
|
mock = MagicMock()
|
||
|
|
mock.enter_room = AsyncMock()
|
||
|
|
mock.emit = AsyncMock()
|
||
|
|
mock.save_session = AsyncMock()
|
||
|
|
mock.get_environ = MagicMock()
|
||
|
|
mock.get_session = AsyncMock()
|
||
|
|
mock.leave_room = AsyncMock()
|
||
|
|
return mock
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def identified_customer(db, test_organization) -> Customer:
|
||
|
|
customer = Customer(
|
||
|
|
organization_id=test_organization.id,
|
||
|
|
email="signed.in@example.com",
|
||
|
|
full_name="Signed In",
|
||
|
|
is_authenticated=True,
|
||
|
|
)
|
||
|
|
db.add(customer)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(customer)
|
||
|
|
return customer
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def connected(db, test_widget, test_ai_config, identified_customer, mock_sio, monkeypatch):
|
||
|
|
"""A widget socket connected as the identified visitor."""
|
||
|
|
token, _, _ = conversation_token.mint(
|
||
|
|
{
|
||
|
|
"sub": str(identified_customer.id),
|
||
|
|
"widget_id": str(test_widget.id),
|
||
|
|
"customer_email": identified_customer.email,
|
||
|
|
},
|
||
|
|
ttl_seconds=600,
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||
|
|
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||
|
|
monkeypatch.setattr(
|
||
|
|
widget_chat,
|
||
|
|
"authenticate_socket_conversation_token",
|
||
|
|
AsyncMock(return_value=(
|
||
|
|
str(test_widget.id),
|
||
|
|
str(test_widget.organization_id),
|
||
|
|
str(identified_customer.id),
|
||
|
|
token,
|
||
|
|
)),
|
||
|
|
)
|
||
|
|
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)
|
||
|
|
return token
|
||
|
|
|
||
|
|
|
||
|
|
def _session_initialized(mock_sio) -> dict:
|
||
|
|
for call in mock_sio.emit.call_args_list:
|
||
|
|
if call[0][0] == 'session_initialized':
|
||
|
|
return call[0][1]
|
||
|
|
raise AssertionError("session_initialized was never emitted")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_new_conversation_is_reported_as_created_and_authenticated(mock_sio, connected):
|
||
|
|
assert await widget_chat.widget_connect("sid-1", {}, {}) is True
|
||
|
|
|
||
|
|
payload = _session_initialized(mock_sio)
|
||
|
|
assert payload['created'] is True
|
||
|
|
assert payload['authenticated'] is True
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_resuming_a_conversation_is_not_reported_as_created(
|
||
|
|
db, test_widget, identified_customer, mock_sio, connected
|
||
|
|
):
|
||
|
|
open_session = SessionToAgent(
|
||
|
|
session_id=uuid4(),
|
||
|
|
customer_id=identified_customer.id,
|
||
|
|
agent_id=test_widget.agent_id,
|
||
|
|
organization_id=test_widget.organization_id,
|
||
|
|
status=SessionStatus.OPEN,
|
||
|
|
)
|
||
|
|
db.add(open_session)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
assert await widget_chat.widget_connect("sid-2", {}, {}) is True
|
||
|
|
|
||
|
|
payload = _session_initialized(mock_sio)
|
||
|
|
assert payload['created'] is False
|
||
|
|
assert payload['session_id'] == str(open_session.session_id)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_an_anonymous_visitor_is_not_reported_as_authenticated(
|
||
|
|
db, test_widget, test_ai_config, test_customer, mock_sio, monkeypatch
|
||
|
|
):
|
||
|
|
anonymous_token, _, _ = conversation_token.mint(
|
||
|
|
{"sub": str(test_customer.id), "widget_id": str(test_widget.id)}, ttl_seconds=600
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(widget_chat, "sio", mock_sio)
|
||
|
|
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||
|
|
monkeypatch.setattr(
|
||
|
|
widget_chat,
|
||
|
|
"authenticate_socket_conversation_token",
|
||
|
|
AsyncMock(return_value=(
|
||
|
|
str(test_widget.id), str(test_widget.organization_id),
|
||
|
|
str(test_customer.id), anonymous_token,
|
||
|
|
)),
|
||
|
|
)
|
||
|
|
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)
|
||
|
|
|
||
|
|
assert await widget_chat.widget_connect("sid-3", {}, {}) is True
|
||
|
|
|
||
|
|
assert _session_initialized(mock_sio)['authenticated'] is False
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_rotated_token_replaces_the_one_the_socket_connected_with(
|
||
|
|
test_widget, identified_customer, mock_sio, connected
|
||
|
|
):
|
||
|
|
await widget_chat.widget_connect("sid-4", {}, {})
|
||
|
|
session = mock_sio.save_session.call_args[0][1]
|
||
|
|
mock_sio.get_session.return_value = session
|
||
|
|
rotated, _, _ = conversation_token.mint(
|
||
|
|
{
|
||
|
|
"sub": str(identified_customer.id),
|
||
|
|
"widget_id": str(test_widget.id),
|
||
|
|
"customer_email": identified_customer.email,
|
||
|
|
},
|
||
|
|
ttl_seconds=600,
|
||
|
|
)
|
||
|
|
|
||
|
|
await widget_chat.handle_refresh_token("sid-4", {"conversation_token": rotated})
|
||
|
|
|
||
|
|
assert mock_sio.save_session.call_args[0][1]['conversation_token'] == rotated
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_token_for_another_customer_is_refused(
|
||
|
|
db, test_widget, test_customer, mock_sio, connected
|
||
|
|
):
|
||
|
|
await widget_chat.widget_connect("sid-5", {}, {})
|
||
|
|
session = mock_sio.save_session.call_args[0][1]
|
||
|
|
mock_sio.get_session.return_value = session
|
||
|
|
someone_else, _, _ = conversation_token.mint(
|
||
|
|
{"sub": str(test_customer.id), "widget_id": str(test_widget.id)}, ttl_seconds=600
|
||
|
|
)
|
||
|
|
|
||
|
|
await widget_chat.handle_refresh_token("sid-5", {"conversation_token": someone_else})
|
||
|
|
|
||
|
|
# The socket keeps its own conversation; a refresh may never move it to another.
|
||
|
|
assert mock_sio.save_session.call_args[0][1]['conversation_token'] == connected
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_conversation_survives_the_token_it_started_with(
|
||
|
|
db, test_widget, identified_customer, mock_sio, connected, monkeypatch
|
||
|
|
):
|
||
|
|
"""The failure this whole path exists for: a chat open longer than its token.
|
||
|
|
|
||
|
|
end_chat re-authenticates from the token the socket connected with, so a
|
||
|
|
conversation whose token was rotated in the page must close on the rotated one -
|
||
|
|
otherwise the visitor's New chat silently does nothing.
|
||
|
|
"""
|
||
|
|
await widget_chat.widget_connect("sid-7", {}, {})
|
||
|
|
session = mock_sio.save_session.call_args[0][1]
|
||
|
|
mock_sio.get_session.return_value = session
|
||
|
|
session_id = session['session_id']
|
||
|
|
|
||
|
|
rotated, _, _ = conversation_token.mint(
|
||
|
|
{
|
||
|
|
"sub": str(identified_customer.id),
|
||
|
|
"widget_id": str(test_widget.id),
|
||
|
|
"customer_email": identified_customer.email,
|
||
|
|
},
|
||
|
|
ttl_seconds=600,
|
||
|
|
)
|
||
|
|
await widget_chat.handle_refresh_token("sid-7", {"conversation_token": rotated})
|
||
|
|
mock_sio.get_session.return_value = mock_sio.save_session.call_args[0][1]
|
||
|
|
|
||
|
|
# The original token is now gone, exactly as it would be past its TTL.
|
||
|
|
monkeypatch.setattr(
|
||
|
|
widget_chat,
|
||
|
|
"authenticate_socket_conversation_token",
|
||
|
|
AsyncMock(side_effect=lambda sid, sess: (
|
||
|
|
(str(test_widget.id), str(test_widget.organization_id),
|
||
|
|
str(identified_customer.id), sess.get('conversation_token'))
|
||
|
|
if sess.get('conversation_token') == rotated
|
||
|
|
else (None, None, None, None)
|
||
|
|
)),
|
||
|
|
)
|
||
|
|
|
||
|
|
await widget_chat.handle_end_chat("sid-7", {"reason": "CUSTOMER_REQUEST"})
|
||
|
|
|
||
|
|
closed = SessionToAgentRepository(db).get_session(session_id)
|
||
|
|
assert closed.status == SessionStatus.CLOSED
|
||
|
|
assert any(call[0][0] == 'chat_ended' for call in mock_sio.emit.call_args_list)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_a_taken_over_conversation_can_still_adopt_a_rotated_token(
|
||
|
|
db, test_widget, test_user, identified_customer, mock_sio, connected
|
||
|
|
):
|
||
|
|
"""A handover lasts far longer than an hour-long token. Refreshing must keep
|
||
|
|
working while a person is handling the chat, or the socket ends up holding a
|
||
|
|
token that no longer authenticates mid-conversation."""
|
||
|
|
await widget_chat.widget_connect("sid-8", {}, {})
|
||
|
|
session = mock_sio.save_session.call_args[0][1]
|
||
|
|
mock_sio.get_session.return_value = session
|
||
|
|
|
||
|
|
# A human agent takes the conversation over.
|
||
|
|
repo = SessionToAgentRepository(db)
|
||
|
|
taken_over = repo.get_session(session['session_id'])
|
||
|
|
taken_over.user_id = test_user.id
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
rotated, _, _ = conversation_token.mint(
|
||
|
|
{
|
||
|
|
"sub": str(identified_customer.id),
|
||
|
|
"widget_id": str(test_widget.id),
|
||
|
|
"customer_email": identified_customer.email,
|
||
|
|
},
|
||
|
|
ttl_seconds=600,
|
||
|
|
)
|
||
|
|
await widget_chat.handle_refresh_token("sid-8", {"conversation_token": rotated})
|
||
|
|
|
||
|
|
assert mock_sio.save_session.call_args[0][1]['conversation_token'] == rotated
|
||
|
|
assert repo.get_session(session['session_id']).user_id == test_user.id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_an_unusable_token_is_refused(mock_sio, connected):
|
||
|
|
await widget_chat.widget_connect("sid-6", {}, {})
|
||
|
|
session = mock_sio.save_session.call_args[0][1]
|
||
|
|
mock_sio.get_session.return_value = session
|
||
|
|
|
||
|
|
await widget_chat.handle_refresh_token("sid-6", {"conversation_token": "not-a-jwt"})
|
||
|
|
|
||
|
|
assert mock_sio.save_session.call_args[0][1]['conversation_token'] == connected
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_closing_a_chat_does_not_depend_on_the_token_still_being_live(
|
||
|
|
db, mock_sio, connected, monkeypatch
|
||
|
|
):
|
||
|
|
"""The symptom the visitor reported: "Authentication failed" on closing the chat.
|
||
|
|
|
||
|
|
end_chat used to re-verify the conversation token, so a chat open longer than
|
||
|
|
its TTL - or one whose JTI Redis had dropped - could not be closed at all. This
|
||
|
|
kills the token by every measure the old path used and expects the close to go
|
||
|
|
through regardless.
|
||
|
|
"""
|
||
|
|
await widget_chat.widget_connect("sid-closing", {}, {})
|
||
|
|
session = mock_sio.save_session.call_args[0][1]
|
||
|
|
mock_sio.get_session.return_value = session
|
||
|
|
session_id = session['session_id']
|
||
|
|
|
||
|
|
# Expired signature, and a JTI Redis no longer knows about.
|
||
|
|
monkeypatch.setattr(
|
||
|
|
widget_chat,
|
||
|
|
"authenticate_socket_conversation_token",
|
||
|
|
AsyncMock(return_value=(None, None, None, None)),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(widget_chat, "verify_conversation_token", lambda token: None)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"app.core.security.verify_conversation_token", lambda token: None
|
||
|
|
)
|
||
|
|
|
||
|
|
await widget_chat.handle_end_chat("sid-closing", {"reason": "CUSTOMER_REQUEST"})
|
||
|
|
|
||
|
|
closed = SessionToAgentRepository(db).get_session(session_id)
|
||
|
|
assert closed.status == SessionStatus.CLOSED
|
||
|
|
assert any(call[0][0] == 'chat_ended' for call in mock_sio.emit.call_args_list)
|
||
|
|
assert not _auth_errors(mock_sio)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_the_contact_form_gets_past_the_guard_after_the_token_lapses(
|
||
|
|
mock_sio, connected, monkeypatch
|
||
|
|
):
|
||
|
|
"""The handoff contact form posts on a connection that may be hours old.
|
||
|
|
|
||
|
|
Refusing it at the identity guard would lose the email a person is waiting on
|
||
|
|
to follow up. This covers the guard only - what the handler then does with the
|
||
|
|
address is the subject of the contact-capture tests.
|
||
|
|
"""
|
||
|
|
await widget_chat.widget_connect("sid-contact", {}, {})
|
||
|
|
session = mock_sio.save_session.call_args[0][1]
|
||
|
|
mock_sio.get_session.return_value = session
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
widget_chat,
|
||
|
|
"authenticate_socket_conversation_token",
|
||
|
|
AsyncMock(return_value=(None, None, None, None)),
|
||
|
|
)
|
||
|
|
|
||
|
|
# An address the handler must reject on its own merits: reaching the
|
||
|
|
# validation error proves it got past the identity guard.
|
||
|
|
await widget_chat.handle_contact_info(
|
||
|
|
"sid-contact", {"form_data": {"email": "not-an-address"}}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert not _auth_errors(mock_sio)
|
||
|
|
assert any(
|
||
|
|
call[0][0] == 'error' and call[0][1].get('type') == 'validation_error'
|
||
|
|
for call in mock_sio.emit.call_args_list
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# Every widget event that used to re-verify the conversation token, with an
|
||
|
|
# argument shape each one accepts. What the handler goes on to do differs; what
|
||
|
|
# they must agree on is that a lapsed token is not a reason to refuse.
|
||
|
|
GUARDED_HANDLERS = [
|
||
|
|
("chat", lambda sid: widget_chat.handle_widget_chat(sid, {"message": "hello"})),
|
||
|
|
("get_chat_history", lambda sid: widget_chat.get_widget_chat_history(sid)),
|
||
|
|
("end_chat", lambda sid: widget_chat.handle_end_chat(sid, {"reason": "CUSTOMER_REQUEST"})),
|
||
|
|
("submit_rating", lambda sid: widget_chat.handle_rating_submission(sid, {"rating": 5})),
|
||
|
|
("get_workflow_state", lambda sid: widget_chat.handle_get_workflow_state(sid)),
|
||
|
|
("proceed_workflow", lambda sid: widget_chat.handle_proceed_workflow(sid, {})),
|
||
|
|
("submit_contact_info", lambda sid: widget_chat.handle_contact_info(
|
||
|
|
sid, {"form_data": {"email": "someone@example.com"}})),
|
||
|
|
("submit_form", lambda sid: widget_chat.handle_form_submission(
|
||
|
|
sid, {"form_data": {"name": "Someone"}})),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@pytest.mark.parametrize("name,call", GUARDED_HANDLERS, ids=[h[0] for h in GUARDED_HANDLERS])
|
||
|
|
async def test_no_widget_event_refuses_a_live_connection_over_a_lapsed_token(
|
||
|
|
name, call, mock_sio, connected, monkeypatch
|
||
|
|
):
|
||
|
|
"""One rule, eight handlers: the token authenticates the connection, and a
|
||
|
|
connection that was authenticated stays authenticated for its lifetime.
|
||
|
|
|
||
|
|
Anything else and a chat open longer than its TTL starts failing an event at a
|
||
|
|
time - which is how this reached us, as messages that stopped sending and a
|
||
|
|
New chat button that did nothing (#315).
|
||
|
|
"""
|
||
|
|
await widget_chat.widget_connect(f"sid-{name}", {}, {})
|
||
|
|
session = mock_sio.save_session.call_args[0][1]
|
||
|
|
mock_sio.get_session.return_value = session
|
||
|
|
mock_sio.emit.reset_mock()
|
||
|
|
|
||
|
|
# Dead by every measure the old per-event check used.
|
||
|
|
monkeypatch.setattr(
|
||
|
|
widget_chat,
|
||
|
|
"authenticate_socket_conversation_token",
|
||
|
|
AsyncMock(return_value=(None, None, None, None)),
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(widget_chat, "verify_conversation_token", lambda token: None)
|
||
|
|
|
||
|
|
await call(f"sid-{name}")
|
||
|
|
|
||
|
|
assert not _auth_errors(mock_sio), f"{name} refused a live connection"
|
||
|
|
|
||
|
|
|
||
|
|
def _auth_errors(mock_sio) -> list:
|
||
|
|
return [
|
||
|
|
call for call in mock_sio.emit.call_args_list
|
||
|
|
if call[0][0] == 'error' and call[0][1].get('type') == 'auth_error'
|
||
|
|
]
|