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.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
import app.main # noqa: F401 — ensures routers are registered on the FastAPI app
|
|
|
|
|
from app.core.application import app
|
|
|
|
|
from app.core.auth import get_current_user, get_current_organization
|
|
|
|
|
from app.database import get_db
|
|
|
|
|
from app.repositories.channels import ChannelAccountRepository, AgentChannelConfigRepository
|
|
|
|
|
|
|
|
|
|
BASE = "/api/v1/channels"
|
|
|
|
|
WEBHOOK_BASE = "/api/v1/webhooks"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def client(db, test_user, test_organization):
|
|
|
|
|
async def override_user():
|
|
|
|
|
return test_user
|
|
|
|
|
|
|
|
|
|
async def override_org():
|
|
|
|
|
return test_organization
|
|
|
|
|
|
|
|
|
|
def override_db():
|
|
|
|
|
yield db
|
|
|
|
|
|
|
|
|
|
app.dependency_overrides[get_current_user] = override_user
|
|
|
|
|
app.dependency_overrides[get_current_organization] = override_org
|
|
|
|
|
app.dependency_overrides[get_db] = override_db
|
|
|
|
|
yield TestClient(app)
|
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def telegram_account(db, test_organization):
|
|
|
|
|
return ChannelAccountRepository(db).create_account(
|
|
|
|
|
organization_id=test_organization.id,
|
|
|
|
|
channel_type="telegram",
|
|
|
|
|
external_account_id="777",
|
|
|
|
|
credentials={"bot_token": "777:token"},
|
|
|
|
|
display_name="@existingbot",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConnectTelegram:
|
|
|
|
|
def test_connect_success(self, client, db, test_agent):
|
|
|
|
|
with patch("app.api.channels.telegram.telegram_api.get_me",
|
|
|
|
|
AsyncMock(return_value={"id": 555, "username": "newbot"})), \
|
|
|
|
|
patch("app.api.channels.telegram.telegram_api.set_webhook",
|
|
|
|
|
AsyncMock(return_value=(True, ""))) as set_webhook:
|
|
|
|
|
response = client.post(f"{BASE}/telegram", json={"bot_token": "555:tok"})
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.json()
|
|
|
|
|
assert data["channel_type"] == "telegram"
|
|
|
|
|
assert data["display_name"] == "@newbot"
|
|
|
|
|
# Webhook registered with the account's secret
|
|
|
|
|
account = ChannelAccountRepository(db).get_by_id(data["id"])
|
|
|
|
|
assert set_webhook.await_args.args[2] == account.webhook_secret
|
|
|
|
|
# Credentials stored encrypted, not plaintext
|
|
|
|
|
assert "555:tok" not in account.encrypted_credentials
|
|
|
|
|
assert ChannelAccountRepository(db).get_credentials(account) == {"bot_token": "555:tok"}
|
|
|
|
|
|
|
|
|
|
def test_connect_invalid_token(self, client):
|
|
|
|
|
with patch("app.api.channels.telegram.telegram_api.get_me", AsyncMock(return_value=None)):
|
|
|
|
|
response = client.post(f"{BASE}/telegram", json={"bot_token": "bad"})
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
|
|
|
|
def test_connect_webhook_failure_rolls_back_new_account(self, client, db):
|
|
|
|
|
with patch("app.api.channels.telegram.telegram_api.get_me",
|
|
|
|
|
AsyncMock(return_value={"id": 556, "username": "failbot"})), \
|
|
|
|
|
patch("app.api.channels.telegram.telegram_api.set_webhook",
|
|
|
|
|
AsyncMock(return_value=(False, "bad webhook: an HTTPS URL must be provided"))):
|
|
|
|
|
response = client.post(f"{BASE}/telegram", json={"bot_token": "556:tok"})
|
|
|
|
|
assert response.status_code == 502
|
|
|
|
|
assert ChannelAccountRepository(db).get_by_external_id("telegram", "556") is None
|
|
|
|
|
|
|
|
|
|
def test_connect_bot_owned_by_other_org(self, client, db, telegram_account):
|
|
|
|
|
# Reassign the existing account to another org
|
|
|
|
|
telegram_account.organization_id = uuid4()
|
|
|
|
|
db.commit()
|
|
|
|
|
with patch("app.api.channels.telegram.telegram_api.get_me",
|
|
|
|
|
AsyncMock(return_value={"id": 777, "username": "existingbot"})):
|
|
|
|
|
response = client.post(f"{BASE}/telegram", json={"bot_token": "777:token"})
|
|
|
|
|
assert response.status_code == 409
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAccountsAndRouting:
|
|
|
|
|
def test_list_accounts(self, client, telegram_account):
|
|
|
|
|
response = client.get(f"{BASE}/accounts")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
accounts = response.json()
|
|
|
|
|
assert len(accounts) == 1
|
|
|
|
|
assert accounts[0]["display_name"] == "@existingbot"
|
|
|
|
|
assert accounts[0]["agent_id"] is None
|
|
|
|
|
assert "encrypted_credentials" not in accounts[0]
|
|
|
|
|
|
|
|
|
|
def test_set_and_clear_agent(self, client, db, telegram_account, test_agent):
|
|
|
|
|
response = client.post(f"{BASE}/agent-config/{telegram_account.id}",
|
|
|
|
|
json={"agent_id": str(test_agent.id)})
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["agent_id"] == str(test_agent.id)
|
|
|
|
|
|
|
|
|
|
response = client.delete(f"{BASE}/agent-config/{telegram_account.id}")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert AgentChannelConfigRepository(db).get_by_account(telegram_account.id) is None
|
|
|
|
|
|
|
|
|
|
def test_set_agent_cross_org_account_404(self, client, test_agent):
|
|
|
|
|
response = client.post(f"{BASE}/agent-config/{uuid4()}",
|
|
|
|
|
json={"agent_id": str(test_agent.id)})
|
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
|
|
|
|
def test_disconnect(self, client, db, telegram_account):
|
|
|
|
|
with patch("app.api.channels.telegram.telegram_api.delete_webhook",
|
|
|
|
|
AsyncMock(return_value=True)):
|
|
|
|
|
response = client.delete(f"{BASE}/telegram/{telegram_account.id}")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert ChannelAccountRepository(db).get_by_id(telegram_account.id) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTelegramWebhook:
|
|
|
|
|
UPDATE = {
|
|
|
|
|
"update_id": 1,
|
|
|
|
|
"message": {
|
|
|
|
|
"message_id": 42,
|
|
|
|
|
"from": {"id": 111, "is_bot": False, "first_name": "Ada"},
|
|
|
|
|
"chat": {"id": 222, "type": "private"},
|
|
|
|
|
"date": 1720000000,
|
|
|
|
|
"text": "hello",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def test_unknown_account_404(self, client):
|
|
|
|
|
response = client.post(f"{WEBHOOK_BASE}/telegram/{uuid4()}", json=self.UPDATE)
|
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
|
|
|
|
def test_bad_secret_403(self, client, telegram_account):
|
|
|
|
|
response = client.post(
|
|
|
|
|
f"{WEBHOOK_BASE}/telegram/{telegram_account.id}",
|
|
|
|
|
json=self.UPDATE,
|
|
|
|
|
headers={"X-Telegram-Bot-Api-Secret-Token": "wrong"},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
|
|
|
|
def test_valid_update_processed_in_background(self, client, telegram_account):
|
|
|
|
|
with patch("app.api.webhooks.telegram.process_channel_message", AsyncMock()) as process:
|
|
|
|
|
response = client.post(
|
|
|
|
|
f"{WEBHOOK_BASE}/telegram/{telegram_account.id}",
|
|
|
|
|
json=self.UPDATE,
|
|
|
|
|
headers={"X-Telegram-Bot-Api-Secret-Token": telegram_account.webhook_secret},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
process.assert_awaited_once()
|
|
|
|
|
inbound = process.await_args.args[1]
|
|
|
|
|
assert inbound.text == "hello"
|
|
|
|
|
assert inbound.external_conversation_id == "222"
|
|
|
|
|
|
|
|
|
|
def test_non_message_update_ack_without_processing(self, client, telegram_account):
|
|
|
|
|
with patch("app.api.webhooks.telegram.process_channel_message", AsyncMock()) as process:
|
|
|
|
|
response = client.post(
|
|
|
|
|
f"{WEBHOOK_BASE}/telegram/{telegram_account.id}",
|
|
|
|
|
json={"update_id": 9, "edited_message": {}},
|
|
|
|
|
headers={"X-Telegram-Bot-Api-Secret-Token": telegram_account.webhook_secret},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
process.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_tap_is_processed_and_answered_in_the_reply(self, client, telegram_account):
|
|
|
|
|
tap = {"update_id": 10, "callback_query": {
|
|
|
|
|
"id": "cb-1", "from": {"id": 111, "first_name": "Ada"}, "data": "qr_0",
|
|
|
|
|
"message": {"message_id": 41, "date": 1720000000, "chat": {"id": 222, "type": "private"},
|
|
|
|
|
"reply_markup": {"inline_keyboard": [[{"text": "Yes", "callback_data": "qr_0"}]]}}}}
|
|
|
|
|
with patch("app.api.webhooks.telegram.process_channel_message", AsyncMock()) as process:
|
|
|
|
|
response = client.post(
|
|
|
|
|
f"{WEBHOOK_BASE}/telegram/{telegram_account.id}", json=tap,
|
|
|
|
|
headers={"X-Telegram-Bot-Api-Secret-Token": telegram_account.webhook_secret})
|
|
|
|
|
assert response.json() == {"method": "answerCallbackQuery", "callback_query_id": "cb-1"}
|
|
|
|
|
inbound = process.await_args.args[1]
|
|
|
|
|
assert (inbound.interactive.id, inbound.text, inbound.reply_to_message_id) == ("qr_0", "Yes", "41")
|
|
|
|
|
|
|
|
|
|
def test_a_second_tap_on_the_same_keyboard_is_answered_but_not_processed(self, client, telegram_account):
|
|
|
|
|
def tap(callback_id, data):
|
|
|
|
|
return {"update_id": 11, "callback_query": {
|
|
|
|
|
"id": callback_id, "from": {"id": 111, "first_name": "Ada"}, "data": data,
|
|
|
|
|
"message": {"message_id": 41, "date": 1720000000, "chat": {"id": 222, "type": "private"},
|
|
|
|
|
"reply_markup": {"inline_keyboard": [[{"text": "Yes", "callback_data": "qr_0"}],
|
|
|
|
|
[{"text": "No", "callback_data": "qr_1"}]]}}}}
|
|
|
|
|
headers = {"X-Telegram-Bot-Api-Secret-Token": telegram_account.webhook_secret}
|
|
|
|
|
process, clear, order = AsyncMock(), AsyncMock(), MagicMock()
|
|
|
|
|
order.attach_mock(process, "process")
|
|
|
|
|
order.attach_mock(clear, "clear")
|
|
|
|
|
with patch("app.api.webhooks.telegram.process_channel_message", process), \
|
|
|
|
|
patch("app.api.webhooks.telegram.is_duplicate_message", side_effect=[False, True]), \
|
|
|
|
|
patch("app.channels.telegram.TelegramAdapter.clear_buttons", clear):
|
|
|
|
|
first = client.post(f"{WEBHOOK_BASE}/telegram/{telegram_account.id}", json=tap("cb-1", "qr_0"),
|
|
|
|
|
headers=headers)
|
|
|
|
|
second = client.post(f"{WEBHOOK_BASE}/telegram/{telegram_account.id}", json=tap("cb-2", "qr_1"),
|
|
|
|
|
headers=headers)
|
|
|
|
|
# The reply goes first: a slow keyboard edit never holds it up
|
|
|
|
|
assert [name for name, _, _ in order.mock_calls] == ["process", "clear"]
|
|
|
|
|
assert "text" not in first.json() and second.json()["text"] == "This choice is no longer available"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEmailConnectSmtp:
|
|
|
|
|
def test_gmail_auth_error_hints_app_password(self, client):
|
|
|
|
|
import smtplib
|
|
|
|
|
err = smtplib.SMTPAuthenticationError(535, b"5.7.8 Username and Password not accepted")
|
|
|
|
|
with patch("app.api.channels.email.validate_smtp", side_effect=err):
|
|
|
|
|
r = client.post("/api/v1/channels/email", json={
|
|
|
|
|
"inbound_address": "support@acme.com",
|
|
|
|
|
"smtp_host": "smtp.gmail.com", "smtp_port": 587,
|
|
|
|
|
"smtp_username": "me@gmail.com", "smtp_password": "wrong"})
|
|
|
|
|
assert r.status_code == 400
|
|
|
|
|
assert "App Password" in r.json()["detail"]
|
|
|
|
|
|
|
|
|
|
def test_non_gmail_auth_error_generic(self, client):
|
|
|
|
|
import smtplib
|
|
|
|
|
err = smtplib.SMTPAuthenticationError(535, b"bad creds")
|
|
|
|
|
with patch("app.api.channels.email.validate_smtp", side_effect=err):
|
|
|
|
|
r = client.post("/api/v1/channels/email", json={
|
|
|
|
|
"inbound_address": "support@acme.com",
|
|
|
|
|
"smtp_host": "smtp.acme.com", "smtp_port": 587,
|
|
|
|
|
"smtp_username": "u", "smtp_password": "p"})
|
|
|
|
|
assert r.status_code == 400
|
|
|
|
|
assert "authentication failed" in r.json()["detail"].lower()
|
|
|
|
|
|
|
|
|
|
def test_smtp_omitted_uses_platform(self, client, db):
|
|
|
|
|
from app.repositories.channels import ChannelAccountRepository
|
|
|
|
|
r = client.post("/api/v1/channels/email", json={"inbound_address": "help@acme.com"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
account = ChannelAccountRepository(db).get_by_external_id("email", "help@acme.com")
|
|
|
|
|
assert ChannelAccountRepository(db).get_credentials(account) == {} # platform fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSmsChannel:
|
|
|
|
|
def test_list_providers(self, client):
|
|
|
|
|
r = client.get("/api/v1/channels/sms/providers")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
names = {p["name"] for p in r.json()}
|
|
|
|
|
assert {"twilio", "vonage", "messagebird", "plivo", "brevo", "sns"} <= names
|
|
|
|
|
|
|
|
|
|
def test_connect_twilio(self, client, db):
|
|
|
|
|
from app.repositories.channels import ChannelAccountRepository
|
|
|
|
|
with patch("app.channels.sms.twilio.TwilioProvider.validate_credentials", AsyncMock()):
|
|
|
|
|
r = client.post("/api/v1/channels/sms", json={
|
|
|
|
|
"provider": "twilio", "phone_number": "+15551230000",
|
|
|
|
|
"credentials": {"account_sid": "AC1", "auth_token": "tok"}})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
data = r.json()
|
|
|
|
|
assert data["channel_type"] == "sms"
|
|
|
|
|
assert "/webhooks/sms/twilio/" in data["webhook_url"]
|
|
|
|
|
acc = ChannelAccountRepository(db).get_by_external_id("sms", "+15551230000")
|
|
|
|
|
assert (acc.settings or {}).get("provider") == "twilio"
|
|
|
|
|
|
|
|
|
|
def test_connect_vonage_stores_provider(self, client, db):
|
|
|
|
|
from app.repositories.channels import ChannelAccountRepository
|
|
|
|
|
r = client.post("/api/v1/channels/sms", json={
|
|
|
|
|
"provider": "vonage", "phone_number": "+15551230001",
|
|
|
|
|
"credentials": {"api_key": "k", "api_secret": "s"}})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
acc = ChannelAccountRepository(db).get_by_external_id("sms", "+15551230001")
|
|
|
|
|
assert acc.settings["provider"] == "vonage"
|
|
|
|
|
assert "/webhooks/sms/vonage/" in r.json()["webhook_url"]
|
|
|
|
|
|
|
|
|
|
def test_connect_unknown_provider(self, client):
|
|
|
|
|
r = client.post("/api/v1/channels/sms", json={
|
|
|
|
|
"provider": "nope", "phone_number": "+1", "credentials": {}})
|
|
|
|
|
assert r.status_code == 400
|
|
|
|
|
|
|
|
|
|
def test_connect_invalid_credentials(self, client):
|
|
|
|
|
r = client.post("/api/v1/channels/sms", json={
|
|
|
|
|
"provider": "vonage", "phone_number": "+1", "credentials": {}})
|
|
|
|
|
assert r.status_code == 400 # missing api_key/secret
|
|
|
|
|
|
|
|
|
|
def test_webhook_routes_to_processor(self, client, db, test_organization):
|
|
|
|
|
from app.repositories.channels import ChannelAccountRepository
|
|
|
|
|
acc = ChannelAccountRepository(db).create_account(
|
|
|
|
|
organization_id=test_organization.id, channel_type="sms",
|
|
|
|
|
external_account_id="+15551239999",
|
|
|
|
|
credentials={"api_key": "k", "api_secret": "s"},
|
|
|
|
|
display_name="SMS", settings={"provider": "vonage"})
|
|
|
|
|
with patch("app.api.webhooks.sms.process_channel_message", AsyncMock()) as proc:
|
|
|
|
|
r = client.post(f"/api/v1/webhooks/sms/vonage/{acc.id}?token={acc.webhook_secret}",
|
|
|
|
|
data={"msisdn": "+44700", "to": "+15551239999",
|
|
|
|
|
"text": "hello", "messageId": "v9"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
proc.assert_awaited_once()
|
|
|
|
|
assert proc.await_args.args[1].text == "hello"
|
|
|
|
|
|
|
|
|
|
def test_webhook_without_token_rejected_for_unsigned_provider(self, client, db, test_organization):
|
|
|
|
|
"""Providers with no webhook signature (here Vonage with no signature secret)
|
|
|
|
|
must be authenticated by the per-account token, or anyone knowing the account
|
|
|
|
|
id could forge inbound SMS."""
|
|
|
|
|
from app.repositories.channels import ChannelAccountRepository
|
|
|
|
|
acc = ChannelAccountRepository(db).create_account(
|
|
|
|
|
organization_id=test_organization.id, channel_type="sms",
|
|
|
|
|
external_account_id="+15551237777",
|
|
|
|
|
credentials={"api_key": "k", "api_secret": "s"},
|
|
|
|
|
display_name="SMS", settings={"provider": "vonage"})
|
|
|
|
|
payload = {"msisdn": "+44700", "to": "+15551237777", "text": "hi", "messageId": "v10"}
|
|
|
|
|
|
|
|
|
|
with patch("app.api.webhooks.sms.process_channel_message", AsyncMock()) as proc:
|
|
|
|
|
no_token = client.post(f"/api/v1/webhooks/sms/vonage/{acc.id}", data=payload)
|
|
|
|
|
wrong_token = client.post(
|
|
|
|
|
f"/api/v1/webhooks/sms/vonage/{acc.id}?token=not-the-secret", data=payload)
|
|
|
|
|
|
|
|
|
|
assert no_token.status_code == 403
|
|
|
|
|
assert wrong_token.status_code == 403
|
|
|
|
|
proc.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
def test_webhook_provider_mismatch_404(self, client, db, test_organization):
|
|
|
|
|
from app.repositories.channels import ChannelAccountRepository
|
|
|
|
|
acc = ChannelAccountRepository(db).create_account(
|
|
|
|
|
organization_id=test_organization.id, channel_type="sms",
|
|
|
|
|
external_account_id="+15551238888", credentials={"auth_token": "t"},
|
|
|
|
|
display_name="SMS", settings={"provider": "twilio"})
|
|
|
|
|
r = client.post(f"/api/v1/webhooks/sms/vonage/{acc.id}", data={"msisdn": "+44"})
|
|
|
|
|
assert r.status_code == 404
|