2026-10-08 22:35:00 +08:00
|
|
|
"""
|
2026-10-26 00:50:43 +08:00
|
|
|
Copyright 2024-2026 ReplyPier
|
2026-10-08 22:35:00 +08:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
POST /widgets/{widget_id}/end-chat — the endpoint the widget calls to close a
|
|
|
|
|
conversation. It shipped untested, and a client bug calling it on every message
|
|
|
|
|
closed live chats the moment a human agent joined.
|
|
|
|
|
"""
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
from app.api import widget as widget_router
|
|
|
|
|
from app.core.security import create_conversation_token
|
|
|
|
|
from app.database import get_db
|
|
|
|
|
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def client(db) -> TestClient:
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(widget_router.router, prefix="/api/v1/widgets")
|
|
|
|
|
|
|
|
|
|
def override_get_db():
|
|
|
|
|
yield db
|
|
|
|
|
|
|
|
|
|
app.dependency_overrides[get_db] = override_get_db
|
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BROWSER_KEY = "browser-a"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def widget_token(test_widget, test_customer) -> str:
|
|
|
|
|
"""The token the widget issued to the browser that opened the session."""
|
|
|
|
|
return create_conversation_token(
|
|
|
|
|
widget_id=str(test_widget.id), customer_id=str(test_customer.id), ckey=BROWSER_KEY
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def open_session(db, test_widget, test_customer, test_agent) -> SessionToAgent:
|
|
|
|
|
session = SessionToAgent(
|
|
|
|
|
session_id=uuid4(),
|
|
|
|
|
customer_id=test_customer.id,
|
|
|
|
|
agent_id=test_agent.id,
|
|
|
|
|
organization_id=test_customer.organization_id,
|
|
|
|
|
status=SessionStatus.OPEN,
|
|
|
|
|
conversation_key=BROWSER_KEY,
|
|
|
|
|
)
|
|
|
|
|
db.add(session)
|
|
|
|
|
db.commit()
|
|
|
|
|
db.refresh(session)
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _url(widget_id, session_id, **params) -> str:
|
|
|
|
|
query = "".join(f"&{k}={v}" for k, v in params.items())
|
|
|
|
|
return f"/api/v1/widgets/{widget_id}/end-chat?session_id={session_id}{query}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _auth(token: str) -> dict:
|
|
|
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_closes_the_session(client, db, test_widget, open_session, widget_token):
|
|
|
|
|
response = client.post(
|
|
|
|
|
_url(test_widget.id, open_session.session_id, reason="CUSTOMER_REQUEST"),
|
|
|
|
|
headers=_auth(widget_token),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["success"] is True
|
|
|
|
|
|
|
|
|
|
db.refresh(open_session)
|
|
|
|
|
assert open_session.status == SessionStatus.CLOSED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_requires_a_token(client, db, test_widget, open_session):
|
|
|
|
|
"""Regression: the token block used to be optional, so a request with no
|
|
|
|
|
Authorization header fell through and closed any session by id."""
|
|
|
|
|
response = client.post(_url(test_widget.id, open_session.session_id))
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
db.refresh(open_session)
|
|
|
|
|
assert open_session.status == SessionStatus.OPEN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rejects_a_garbage_token(client, db, test_widget, open_session):
|
|
|
|
|
response = client.post(
|
|
|
|
|
_url(test_widget.id, open_session.session_id),
|
|
|
|
|
headers=_auth("not-a-jwt"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
db.refresh(open_session)
|
|
|
|
|
assert open_session.status == SessionStatus.OPEN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rejects_a_token_issued_for_another_widget(client, db, test_widget, open_session, test_customer):
|
|
|
|
|
other_widget_token = create_conversation_token(
|
|
|
|
|
widget_id=str(uuid4()), customer_id=str(test_customer.id)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = client.post(
|
|
|
|
|
_url(test_widget.id, open_session.session_id),
|
|
|
|
|
headers=_auth(other_widget_token),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
db.refresh(open_session)
|
|
|
|
|
assert open_session.status == SessionStatus.OPEN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cannot_close_another_customers_session(client, db, test_widget, open_session):
|
|
|
|
|
"""The token proves who the caller is, not that the session is theirs."""
|
|
|
|
|
stranger_token = create_conversation_token(
|
|
|
|
|
widget_id=str(test_widget.id), customer_id=str(uuid4())
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = client.post(
|
|
|
|
|
_url(test_widget.id, open_session.session_id),
|
|
|
|
|
headers=_auth(stranger_token),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
db.refresh(open_session)
|
|
|
|
|
assert open_session.status == SessionStatus.OPEN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cannot_close_another_browsers_session_of_the_same_customer(
|
|
|
|
|
client, db, test_widget, open_session, test_customer
|
|
|
|
|
):
|
|
|
|
|
"""Naming the customer is not enough: a token the widget issued to another
|
|
|
|
|
browser (e.g. after someone typed this customer's email) is out of scope."""
|
|
|
|
|
other_browser_token = create_conversation_token(
|
|
|
|
|
widget_id=str(test_widget.id), customer_id=str(test_customer.id), ckey="browser-b"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = client.post(
|
|
|
|
|
_url(test_widget.id, open_session.session_id),
|
|
|
|
|
headers=_auth(other_browser_token),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
db.refresh(open_session)
|
|
|
|
|
assert open_session.status == SessionStatus.OPEN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_session_is_not_found(client, test_widget, widget_token):
|
|
|
|
|
response = client.post(
|
|
|
|
|
_url(test_widget.id, uuid4()),
|
|
|
|
|
headers=_auth(widget_token),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 404
|