test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
8e3a4d95da
commit
164f5f9615
97 files changed
+37281
No files matched your search
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
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."
|
||||
Reference in new issue
Block a user