1421 lines
70 KiB
Python
1421 lines
70 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.
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.core.security import get_password_hash
|
|
from app.models.channels import ChannelAccount, ChannelType
|
|
from app.models.permission import Permission
|
|
from app.models.role import Role
|
|
from app.models.user import User
|
|
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.core.config import settings
|
|
from app.database import get_db
|
|
from app.api.channels.meta import _seal_signup_pages
|
|
from app.channels.base import SendResult
|
|
from app.repositories.channels import ChannelAccountRepository
|
|
from app.repositories.channels.accounts import needs_reconnect
|
|
|
|
BASE = "/api/v1/channels/meta"
|
|
WEBHOOK = "/api/v1/webhooks/meta"
|
|
|
|
APP_SECRET = "test-meta-secret"
|
|
|
|
|
|
@pytest.fixture
|
|
def client(db, test_user, test_organization, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_APP_SECRET", APP_SECRET)
|
|
monkeypatch.setattr(settings, "META_WEBHOOK_VERIFY_TOKEN", "vtok")
|
|
|
|
# Reading templates is inbox work; SENDING one is a manage action — a
|
|
# billable Meta message with opt-in obligations, gated like takeover and
|
|
# reassign. The shared test_role only carries admin perms, so grant both.
|
|
from app.models.permission import Permission
|
|
for name in ("view_all_chats", "manage_all_chats"):
|
|
perm = Permission(name=name)
|
|
db.add(perm)
|
|
test_user.role.permissions.append(perm)
|
|
db.commit()
|
|
|
|
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()
|
|
|
|
|
|
def signed(payload: dict):
|
|
body = json.dumps(payload).encode()
|
|
sig = "sha256=" + hmac.new(APP_SECRET.encode(), body, hashlib.sha256).hexdigest()
|
|
return body, {"X-Hub-Signature-256": sig, "Content-Type": "application/json"}
|
|
|
|
|
|
@pytest.fixture
|
|
def whatsapp_account(db, test_organization):
|
|
return ChannelAccountRepository(db).create_account(
|
|
organization_id=test_organization.id,
|
|
channel_type="whatsapp",
|
|
external_account_id="PN123",
|
|
credentials={"access_token": "EAAG-token"},
|
|
display_name="Acme (+1555…)",
|
|
)
|
|
|
|
|
|
class TestMetaWebhook:
|
|
WA_PAYLOAD = {
|
|
"object": "whatsapp_business_account",
|
|
"entry": [{"changes": [{"value": {
|
|
"metadata": {"phone_number_id": "PN123"},
|
|
"contacts": [{"profile": {"name": "Ada"}, "wa_id": "447"}],
|
|
"messages": [{"from": "447", "id": "wamid.1", "type": "text",
|
|
"text": {"body": "hi"}, "timestamp": "1720000000"}],
|
|
}}]}],
|
|
}
|
|
|
|
def test_get_challenge_ok(self, client):
|
|
r = client.get(WEBHOOK, params={
|
|
"hub.mode": "subscribe", "hub.verify_token": "vtok", "hub.challenge": "42"})
|
|
assert r.status_code == 200
|
|
assert r.text == "42"
|
|
|
|
def test_get_challenge_bad_token(self, client):
|
|
r = client.get(WEBHOOK, params={
|
|
"hub.mode": "subscribe", "hub.verify_token": "nope", "hub.challenge": "42"})
|
|
assert r.status_code == 403
|
|
|
|
def test_post_bad_signature_403(self, client):
|
|
r = client.post(WEBHOOK, content=json.dumps(self.WA_PAYLOAD),
|
|
headers={"X-Hub-Signature-256": "sha256=bad",
|
|
"Content-Type": "application/json"})
|
|
assert r.status_code == 403
|
|
|
|
def test_post_routes_whatsapp_to_processor(self, client, whatsapp_account):
|
|
body, headers = signed(self.WA_PAYLOAD)
|
|
with patch("app.api.webhooks.meta.process_channel_message", AsyncMock()) as process:
|
|
r = client.post(WEBHOOK, content=body, headers=headers)
|
|
assert r.status_code == 200
|
|
process.assert_awaited_once()
|
|
account_id, inbound = process.await_args.args
|
|
assert account_id == whatsapp_account.id
|
|
assert inbound.text == "hi"
|
|
|
|
def test_post_unknown_account_acked_not_processed(self, client):
|
|
body, headers = signed(self.WA_PAYLOAD) # PN123 not connected
|
|
with patch("app.api.webhooks.meta.process_channel_message", AsyncMock()) as process:
|
|
r = client.post(WEBHOOK, content=body, headers=headers)
|
|
assert r.status_code == 200
|
|
process.assert_not_awaited()
|
|
|
|
def test_post_unknown_object_ignored(self, client):
|
|
body, headers = signed({"object": "permissions", "entry": []})
|
|
r = client.post(WEBHOOK, content=body, headers=headers)
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "ignored"
|
|
|
|
STATUS_PAYLOAD = {
|
|
"object": "whatsapp_business_account",
|
|
"entry": [{"changes": [{"value": {
|
|
"metadata": {"phone_number_id": "PN123"},
|
|
"statuses": [{"id": "wamid.OUT", "status": "read", "recipient_id": "447",
|
|
"timestamp": "1720000000"}],
|
|
}}]}],
|
|
}
|
|
|
|
def test_post_routes_receipts_to_status_processor(self, client, whatsapp_account):
|
|
body, headers = signed(self.STATUS_PAYLOAD)
|
|
with patch("app.api.webhooks.meta.process_status_event", AsyncMock()) as process, \
|
|
patch("app.api.webhooks.meta.process_channel_message", AsyncMock()) as message:
|
|
r = client.post(WEBHOOK, content=body, headers=headers)
|
|
assert r.status_code == 200
|
|
message.assert_not_awaited()
|
|
account_id, event = process.await_args.args
|
|
assert account_id == whatsapp_account.id
|
|
assert (event.external_message_id, event.status.value) == ("wamid.OUT", "read")
|
|
|
|
def test_several_receipts_and_a_message_in_one_payload(self, client, whatsapp_account):
|
|
payload = {
|
|
"object": "whatsapp_business_account",
|
|
"entry": [{"changes": [{"value": {
|
|
"metadata": {"phone_number_id": "PN123"},
|
|
"messages": [{"from": "447", "id": "wamid.IN9", "type": "text",
|
|
"text": {"body": "hi"}, "timestamp": "1720000000"}],
|
|
"statuses": [
|
|
{"id": "wamid.A", "status": "read", "recipient_id": "447"},
|
|
{"id": "wamid.A", "status": "delivered", "recipient_id": "447"},
|
|
{"id": "wamid.B", "status": "sent", "recipient_id": "447"},
|
|
],
|
|
}}]}],
|
|
}
|
|
body, headers = signed(payload)
|
|
with patch("app.api.webhooks.meta.process_status_event", AsyncMock()) as status, \
|
|
patch("app.api.webhooks.meta.process_channel_message", AsyncMock()) as message, \
|
|
patch("app.api.webhooks.meta.is_duplicate_message", return_value=False) as dedupe:
|
|
r = client.post(WEBHOOK, content=body, headers=headers)
|
|
assert r.status_code == 200
|
|
message.assert_awaited_once()
|
|
assert [(c.args[1].external_message_id, c.args[1].status.value)
|
|
for c in status.await_args_list] == [("wamid.A", "read"), ("wamid.A", "delivered"),
|
|
("wamid.B", "sent")]
|
|
# One dedupe key per message and state
|
|
keys = [c.args[1] for c in dedupe.call_args_list]
|
|
assert f"{whatsapp_account.id}:wamid.A:read" in keys
|
|
assert f"{whatsapp_account.id}:wamid.A:delivered" in keys
|
|
|
|
def test_a_redelivered_receipt_is_processed_once(self, client, whatsapp_account):
|
|
body, headers = signed(self.STATUS_PAYLOAD)
|
|
seen = set()
|
|
|
|
def _claim(channel, key):
|
|
duplicate = key in seen
|
|
seen.add(key)
|
|
return duplicate
|
|
|
|
with patch("app.api.webhooks.meta.process_status_event", AsyncMock()) as status, \
|
|
patch("app.api.webhooks.meta.is_duplicate_message", side_effect=_claim):
|
|
client.post(WEBHOOK, content=body, headers=headers)
|
|
client.post(WEBHOOK, content=body, headers=headers)
|
|
status.assert_awaited_once()
|
|
|
|
def test_receipts_for_unknown_accounts_are_dropped(self, client):
|
|
body, headers = signed(self.STATUS_PAYLOAD) # PN123 not connected
|
|
with patch("app.api.webhooks.meta.process_status_event", AsyncMock()) as process:
|
|
r = client.post(WEBHOOK, content=body, headers=headers)
|
|
assert r.status_code == 200
|
|
process.assert_not_awaited()
|
|
|
|
|
|
class TestMetaConnect:
|
|
def test_connect_whatsapp_success(self, client, db):
|
|
graph = AsyncMock(return_value=(True, {"display_phone_number": "1555", "verified_name": "Acme"}))
|
|
with patch("app.api.channels.meta.graph_get", graph), \
|
|
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)):
|
|
r = client.post(f"{BASE}/whatsapp", json={
|
|
"phone_number_id": "PN9", "access_token": "EAAG-x", "waba_id": "WABA9"})
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["channel_type"] == "whatsapp"
|
|
assert "Acme" in data["display_name"]
|
|
account = ChannelAccountRepository(db).get_by_external_id("whatsapp", "PN9")
|
|
assert ChannelAccountRepository(db).get_credentials(account)["access_token"] == "EAAG-x"
|
|
|
|
def test_connect_whatsapp_invalid_credentials(self, client):
|
|
graph = AsyncMock(return_value=(False, {"error": {"message": "Invalid OAuth token"}}))
|
|
with patch("app.api.channels.meta.graph_get", graph):
|
|
r = client.post(f"{BASE}/whatsapp", json={
|
|
"phone_number_id": "PN9", "access_token": "bad"})
|
|
assert r.status_code == 400
|
|
|
|
def test_connect_messenger_success(self, client, db):
|
|
# A real messaging token carries pages_messaging but not
|
|
# pages_read_engagement, so the page name is unreadable — that must not
|
|
# block the connect, only the label.
|
|
inspect = AsyncMock(return_value=(True, {
|
|
"type": "PAGE", "profile_id": "PAGE9", "is_valid": True,
|
|
"scopes": ["pages_messaging"]}))
|
|
name = AsyncMock(return_value=(False, {"error": {"message": "needs pages_read_engagement"}}))
|
|
with patch("app.api.channels.meta.debug_token", inspect), \
|
|
patch("app.api.channels.meta.graph_get", name), \
|
|
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)):
|
|
r = client.post(f"{BASE}/messenger", json={
|
|
"page_id": "PAGE9", "page_access_token": "EAAG-x"})
|
|
assert r.status_code == 200
|
|
assert r.json()["display_name"] == "PAGE9"
|
|
account = ChannelAccountRepository(db).get_by_external_id("messenger", "PAGE9")
|
|
assert ChannelAccountRepository(db).get_credentials(account)["access_token"] == "EAAG-x"
|
|
|
|
def test_connect_messenger_uses_page_name_when_readable(self, client):
|
|
inspect = AsyncMock(return_value=(True, {
|
|
"type": "PAGE", "profile_id": "PAGE9", "is_valid": True}))
|
|
with patch("app.api.channels.meta.debug_token", inspect), \
|
|
patch("app.api.channels.meta.graph_get", AsyncMock(return_value=(True, {"name": "Acme Support"}))), \
|
|
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)):
|
|
r = client.post(f"{BASE}/messenger", json={
|
|
"page_id": "PAGE9", "page_access_token": "EAAG-x"})
|
|
assert r.status_code == 200
|
|
assert r.json()["display_name"] == "Acme Support"
|
|
|
|
def test_connect_messenger_token_page_mismatch(self, client):
|
|
# Webhooks route on page id; a token for a different page would leave
|
|
# every inbound message unmatched.
|
|
inspect = AsyncMock(return_value=(True, {
|
|
"type": "PAGE", "profile_id": "OTHER_PAGE", "is_valid": True}))
|
|
with patch("app.api.channels.meta.debug_token", inspect):
|
|
r = client.post(f"{BASE}/messenger", json={
|
|
"page_id": "PAGE9", "page_access_token": "EAAG-x"})
|
|
assert r.status_code == 400
|
|
assert "OTHER_PAGE" in r.json()["detail"]
|
|
|
|
def test_connect_messenger_rejects_a_user_token(self, client):
|
|
inspect = AsyncMock(return_value=(True, {
|
|
"type": "USER", "profile_id": "PAGE9", "is_valid": True}))
|
|
with patch("app.api.channels.meta.debug_token", inspect):
|
|
r = client.post(f"{BASE}/messenger", json={
|
|
"page_id": "PAGE9", "page_access_token": "EAAG-x"})
|
|
assert r.status_code == 400
|
|
assert "Page access token" in r.json()["detail"]
|
|
|
|
def test_connect_messenger_rejects_an_expired_token(self, client):
|
|
inspect = AsyncMock(return_value=(True, {
|
|
"is_valid": False, "error": {"message": "Session has expired"}}))
|
|
with patch("app.api.channels.meta.debug_token", inspect):
|
|
r = client.post(f"{BASE}/messenger", json={
|
|
"page_id": "PAGE9", "page_access_token": "EAAG-x"})
|
|
assert r.status_code == 400
|
|
assert "Session has expired" in r.json()["detail"]
|
|
|
|
def test_connect_instagram_success(self, client, db):
|
|
graph = AsyncMock(return_value=(True, {"id": "IG7", "username": "acme"}))
|
|
with patch("app.api.channels.meta.graph_get", graph):
|
|
r = client.post(f"{BASE}/instagram", json={
|
|
"ig_id": "IG7", "page_access_token": "EAAG-x"})
|
|
assert r.status_code == 200
|
|
assert r.json()["display_name"] == "@acme"
|
|
|
|
def test_reconnect_other_org_409(self, client, db, whatsapp_account):
|
|
whatsapp_account.organization_id = uuid4()
|
|
db.commit()
|
|
graph = AsyncMock(return_value=(True, {"display_phone_number": "1555", "verified_name": "Acme"}))
|
|
with patch("app.api.channels.meta.graph_get", graph), \
|
|
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)):
|
|
r = client.post(f"{BASE}/whatsapp", json={
|
|
"phone_number_id": "PN123", "access_token": "EAAG-x"})
|
|
assert r.status_code == 409
|
|
|
|
def test_disconnect(self, client, db, whatsapp_account):
|
|
r = client.delete(f"{BASE}/{whatsapp_account.id}")
|
|
assert r.status_code == 200
|
|
assert ChannelAccountRepository(db).get_by_id(whatsapp_account.id) is None
|
|
|
|
|
|
class TestTemplateSend:
|
|
def test_send_template(self, client, db, whatsapp_account, test_agent, test_customer, test_organization):
|
|
from app.repositories.channels import ChannelConversationRepository
|
|
from app.repositories.session_to_agent import SessionToAgentRepository
|
|
session = SessionToAgentRepository(db).create_session(
|
|
session_id=uuid4(), agent_id=test_agent.id, customer_id=test_customer.id,
|
|
organization_id=test_organization.id, channel="whatsapp")
|
|
ChannelConversationRepository(db).create(
|
|
channel_account_id=whatsapp_account.id, channel_type="whatsapp",
|
|
external_conversation_id="447", external_user_id="447",
|
|
session_id=session.session_id, organization_id=test_organization.id)
|
|
|
|
send = AsyncMock(return_value=SendResult(ok=True, external_message_id="wamid.T"))
|
|
with patch("app.channels.whatsapp.WhatsAppAdapter.send_template", send):
|
|
r = client.post(f"{BASE}/whatsapp/{whatsapp_account.id}/send-template", json={
|
|
"session_id": str(session.session_id),
|
|
"template_name": "hello_world"})
|
|
assert r.status_code == 200
|
|
assert r.json()["external_message_id"] == "wamid.T"
|
|
assert send.await_args.kwargs["template_name"] == "hello_world"
|
|
|
|
def test_send_template_unknown_session_404(self, client, whatsapp_account):
|
|
r = client.post(f"{BASE}/whatsapp/{whatsapp_account.id}/send-template", json={
|
|
"session_id": str(uuid4()), "template_name": "hello_world"})
|
|
assert r.status_code == 404
|
|
|
|
|
|
@pytest.fixture
|
|
def waba_account(db, test_organization):
|
|
"""A number connected with its WABA id — the prerequisite for templates."""
|
|
return ChannelAccountRepository(db).create_account(
|
|
organization_id=test_organization.id,
|
|
channel_type="whatsapp",
|
|
external_account_id="PN777",
|
|
credentials={"access_token": "EAAG-token", "waba_id": "WABA9"},
|
|
display_name="Acme (+1777…)",
|
|
)
|
|
|
|
|
|
class TestTemplateManagement:
|
|
def test_list_templates(self, client, waba_account):
|
|
graph = AsyncMock(return_value=(True, {"data": [
|
|
{"name": "hello_world", "status": "APPROVED", "category": "UTILITY",
|
|
"language": "en_US", "components": [{"type": "BODY", "text": "Hi"}]},
|
|
{"name": "promo", "status": "PENDING", "category": "MARKETING"},
|
|
]}))
|
|
with patch("app.channels.meta_base.graph_get", graph):
|
|
r = client.get(f"{BASE}/whatsapp/{waba_account.id}/templates")
|
|
|
|
assert r.status_code == 200
|
|
assert [t["name"] for t in r.json()] == ["hello_world", "promo"]
|
|
# Templates live on the WABA, not the phone number
|
|
assert graph.await_args.args[0] == "WABA9/message_templates"
|
|
|
|
def test_list_templates_graph_failure_502(self, client, waba_account):
|
|
graph = AsyncMock(return_value=(False, {"error": {"message": "Bad token"}}))
|
|
with patch("app.channels.meta_base.graph_get", graph):
|
|
r = client.get(f"{BASE}/whatsapp/{waba_account.id}/templates")
|
|
|
|
assert r.status_code == 502
|
|
assert r.json()["detail"] == "Bad token"
|
|
|
|
def test_templates_require_waba_id(self, client, whatsapp_account):
|
|
"""Numbers connected without a WABA id can message but not manage
|
|
templates — that must be a clear 400, not a Graph failure."""
|
|
r = client.get(f"{BASE}/whatsapp/{whatsapp_account.id}/templates")
|
|
assert r.status_code == 400
|
|
assert "WhatsApp Business Account ID" in r.json()["detail"]
|
|
|
|
def test_templates_reject_non_whatsapp_account(self, client, db, test_organization):
|
|
messenger = ChannelAccountRepository(db).create_account(
|
|
organization_id=test_organization.id, channel_type="messenger",
|
|
external_account_id="PAGE9", credentials={"access_token": "t"},
|
|
display_name="Page")
|
|
r = client.get(f"{BASE}/whatsapp/{messenger.id}/templates")
|
|
assert r.status_code == 404
|
|
|
|
def test_templates_reject_other_orgs_account(self, client, db, waba_account):
|
|
waba_account.organization_id = uuid4()
|
|
db.commit()
|
|
r = client.get(f"{BASE}/whatsapp/{waba_account.id}/templates")
|
|
assert r.status_code == 404
|
|
|
|
def test_list_templates_follows_paging_cursor(self, client, waba_account):
|
|
"""A template that exists but isn't listed can't be picked to reopen a
|
|
window, so a full page must be followed by the next one."""
|
|
first = {"data": [{"name": f"t{i}"} for i in range(100)],
|
|
"paging": {"cursors": {"after": "CUR2"}}}
|
|
second = {"data": [{"name": "t100"}]}
|
|
graph = AsyncMock(side_effect=[(True, first), (True, second)])
|
|
with patch("app.channels.meta_base.graph_get", graph):
|
|
r = client.get(f"{BASE}/whatsapp/{waba_account.id}/templates")
|
|
|
|
assert r.status_code == 200
|
|
assert len(r.json()) == 101
|
|
assert graph.await_args_list[1].kwargs["params"]["after"] == "CUR2"
|
|
|
|
def test_list_templates_stops_on_short_page(self, client, waba_account):
|
|
graph = AsyncMock(return_value=(True, {
|
|
"data": [{"name": "only"}], "paging": {"cursors": {"after": "CUR2"}}}))
|
|
with patch("app.channels.meta_base.graph_get", graph):
|
|
r = client.get(f"{BASE}/whatsapp/{waba_account.id}/templates")
|
|
|
|
assert r.status_code == 200
|
|
assert graph.await_count == 1
|
|
|
|
class TestEmbeddedSignupConfig:
|
|
"""Embedded Signup onboards a number under ReplyPier's own approved Meta
|
|
app, so without a config id it structurally cannot work — that is the whole
|
|
gate. Like every other integration, it is not restricted by plan."""
|
|
|
|
def test_disabled_without_config_id(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "")
|
|
r = client.get(f"{BASE}/embedded-signup-config")
|
|
|
|
assert r.status_code == 200
|
|
assert r.json()["enabled"] is False
|
|
|
|
def test_enabled_with_config_id(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
|
monkeypatch.setattr(settings, "META_APP_ID", "APP1")
|
|
r = client.get(f"{BASE}/embedded-signup-config")
|
|
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert (body["enabled"], body["config_id"], body["app_id"]) == (True, "CFG1", "APP1")
|
|
|
|
def test_config_not_leaked_when_disabled(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "")
|
|
monkeypatch.setattr(settings, "META_APP_ID", "APP1")
|
|
body = client.get(f"{BASE}/embedded-signup-config").json()
|
|
|
|
assert body["config_id"] is None
|
|
assert body["app_id"] is None
|
|
|
|
def test_not_restricted_by_plan(self, client, monkeypatch):
|
|
"""Integrations are not plan-gated, so a config id is sufficient — the
|
|
org in these tests has no subscription at all."""
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
|
assert client.get(f"{BASE}/embedded-signup-config").json()["enabled"] is True
|
|
|
|
def test_messenger_config_id_returned_for_messenger(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_MESSENGER_CONFIG_ID", "MCFG")
|
|
monkeypatch.setattr(settings, "META_APP_ID", "APP1")
|
|
body = client.get(f"{BASE}/embedded-signup-config?channel=messenger").json()
|
|
assert (body["enabled"], body["config_id"], body["app_id"]) == (True, "MCFG", "APP1")
|
|
|
|
def test_whatsapp_config_id_not_served_to_messenger(self, client, monkeypatch):
|
|
"""The WhatsApp config id must not leak on a Messenger request: Messenger
|
|
has its own config, and an unset one means the manual form, not WhatsApp's."""
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
|
monkeypatch.setattr(settings, "META_MESSENGER_CONFIG_ID", "")
|
|
body = client.get(f"{BASE}/embedded-signup-config?channel=messenger").json()
|
|
assert body["enabled"] is False
|
|
assert body["config_id"] is None
|
|
|
|
def test_unknown_channel_404(self, client):
|
|
assert client.get(f"{BASE}/embedded-signup-config?channel=carrier-pigeon").status_code == 404
|
|
|
|
|
|
class TestSignupAllowlist:
|
|
"""While the Meta app is in App Review its login only works for people with
|
|
a role on the app, so SIGNUP_ALLOWED_EMAILS keeps the button off every
|
|
account it would only fail for. Empty means everyone — the end state, and
|
|
what every other test in this file relies on."""
|
|
|
|
def test_open_to_everyone_when_unset(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "SIGNUP_ALLOWED_EMAILS", "")
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
|
assert client.get(f"{BASE}/embedded-signup-config").json()["enabled"] is True
|
|
|
|
def test_disabled_for_an_account_not_on_the_list(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
|
monkeypatch.setattr(settings, "META_APP_ID", "APP1")
|
|
monkeypatch.setattr(settings, "SIGNUP_ALLOWED_EMAILS", "someone.else@example.com")
|
|
body = client.get(f"{BASE}/embedded-signup-config").json()
|
|
|
|
assert body["enabled"] is False
|
|
# The ids are what make the popup possible, so a gated-out account must
|
|
# not receive them either.
|
|
assert body["config_id"] is None
|
|
assert body["app_id"] is None
|
|
|
|
def test_enabled_for_an_account_on_the_list(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
|
monkeypatch.setattr(settings, "SIGNUP_ALLOWED_EMAILS",
|
|
"first@example.com, test.user@example.com")
|
|
assert client.get(f"{BASE}/embedded-signup-config").json()["enabled"] is True
|
|
|
|
def test_matching_ignores_case_and_padding(self, client, monkeypatch):
|
|
"""The list is hand-edited in an env var, so stray spacing or a
|
|
capitalised address must not silently lock the account out."""
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
|
monkeypatch.setattr(settings, "SIGNUP_ALLOWED_EMAILS", " Test.User@Example.COM ,")
|
|
assert client.get(f"{BASE}/embedded-signup-config").json()["enabled"] is True
|
|
|
|
def test_connect_is_refused_for_an_account_not_on_the_list(self, client, monkeypatch):
|
|
"""The gate is enforced server-side, not just hidden in the UI — the
|
|
config endpoint only decides which pane is shown."""
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
|
monkeypatch.setattr(settings, "SIGNUP_ALLOWED_EMAILS", "someone.else@example.com")
|
|
exchange = AsyncMock(return_value=(True, {"access_token": "T"}))
|
|
with patch("app.api.channels.meta.exchange_signup_code", exchange):
|
|
r = client.post(f"{BASE}/whatsapp/embedded-signup", json={
|
|
"code": "CODE", "waba_id": "W1", "phone_number_id": "P1"})
|
|
|
|
assert r.status_code == 403
|
|
# Refused before the code is spent, so a retry once allowed still works.
|
|
exchange.assert_not_awaited()
|
|
|
|
def test_messenger_signup_is_refused_for_an_account_not_on_the_list(
|
|
self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_MESSENGER_CONFIG_ID", "MCFG")
|
|
monkeypatch.setattr(settings, "SIGNUP_ALLOWED_EMAILS", "someone.else@example.com")
|
|
r = client.post(f"{BASE}/messenger/signup/pages",
|
|
json={"code": "CODE", "redirect_uri": "https://x/cb"})
|
|
|
|
assert r.status_code == 403
|
|
|
|
|
|
class TestEmbeddedSignupConnect:
|
|
ES = f"{BASE}/whatsapp/embedded-signup"
|
|
PAYLOAD = {"code": "AQD-code", "waba_id": "WABA9", "phone_number_id": "PN555"}
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def enabled(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "CFG1")
|
|
|
|
# The WABA's numbers, as Graph lists them for the exchanged token
|
|
WABA_NUMBERS = (True, {"data": [
|
|
{"id": "PN555", "display_phone_number": "1555", "verified_name": "Acme"},
|
|
]})
|
|
|
|
def test_exchanges_code_and_connects_number(self, client, db, monkeypatch):
|
|
exchange = AsyncMock(return_value=(True, {"access_token": "EAAG-business"}))
|
|
register = AsyncMock(return_value=(True, {"success": True}))
|
|
numbers = AsyncMock(return_value=self.WABA_NUMBERS)
|
|
subscribe = AsyncMock(return_value=True)
|
|
with patch("app.api.channels.meta.exchange_signup_code", exchange), \
|
|
patch("app.api.channels.meta.register_phone_number", register), \
|
|
patch("app.api.channels.meta.graph_get", numbers), \
|
|
patch("app.api.channels.meta.subscribe_app", subscribe):
|
|
r = client.post(self.ES, json=self.PAYLOAD)
|
|
|
|
assert r.status_code == 200
|
|
assert "Acme" in r.json()["display_name"]
|
|
exchange.assert_awaited_once_with("AQD-code")
|
|
# Same persistence as a manual connect — only the credentials differ
|
|
account = ChannelAccountRepository(db).get_by_external_id("whatsapp", "PN555")
|
|
credentials = ChannelAccountRepository(db).get_credentials(account)
|
|
assert credentials["access_token"] == "EAAG-business"
|
|
assert credentials["waba_id"] == "WABA9"
|
|
# The PIN is kept: re-registering the number later needs the same one
|
|
assert credentials["verification_pin"].isdigit()
|
|
subscribe.assert_awaited_once()
|
|
assert subscribe.await_args.args[0] == "WABA9"
|
|
|
|
def test_stale_code_is_400(self, client):
|
|
exchange = AsyncMock(return_value=(False, {"error": {"message": "Code expired"}}))
|
|
with patch("app.api.channels.meta.exchange_signup_code", exchange):
|
|
r = client.post(self.ES, json=self.PAYLOAD)
|
|
|
|
assert r.status_code == 400
|
|
assert r.json()["detail"] == "Code expired"
|
|
|
|
def test_exchange_without_token_is_400(self, client):
|
|
"""A 200 that carries no token must not connect a tokenless account."""
|
|
exchange = AsyncMock(return_value=(True, {}))
|
|
with patch("app.api.channels.meta.exchange_signup_code", exchange):
|
|
r = client.post(self.ES, json=self.PAYLOAD)
|
|
|
|
assert r.status_code == 400
|
|
|
|
def test_failed_registration_still_connects_without_storing_pin(self, client, db):
|
|
"""Registration can be retried; losing the token would mean redoing the
|
|
whole signup, so a failure here must not abort the connect. But a PIN we
|
|
never set must not be stored — Meta cannot read the real one back."""
|
|
with patch("app.api.channels.meta.exchange_signup_code",
|
|
AsyncMock(return_value=(True, {"access_token": "EAAG-business"}))), \
|
|
patch("app.api.channels.meta.register_phone_number",
|
|
AsyncMock(return_value=(False, {"error": {"message": "already registered"}}))), \
|
|
patch("app.api.channels.meta.graph_get", AsyncMock(return_value=self.WABA_NUMBERS)), \
|
|
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)):
|
|
r = client.post(self.ES, json=self.PAYLOAD)
|
|
|
|
assert r.status_code == 200
|
|
repo = ChannelAccountRepository(db)
|
|
account = repo.get_by_external_id("whatsapp", "PN555")
|
|
assert account is not None
|
|
assert "verification_pin" not in repo.get_credentials(account)
|
|
|
|
def test_rejects_a_number_not_on_the_claimed_waba(self, client, db):
|
|
"""The code proves a signup happened, not who owns the number in the
|
|
body. Webhooks route by phone number id with no org scoping, so pairing
|
|
a real code with someone else's number would steal their messages."""
|
|
other_waba = (True, {"data": [{"id": "PN_ATTACKER_OWNS"}]})
|
|
with patch("app.api.channels.meta.exchange_signup_code",
|
|
AsyncMock(return_value=(True, {"access_token": "EAAG-attacker"}))), \
|
|
patch("app.api.channels.meta.graph_get", AsyncMock(return_value=other_waba)), \
|
|
patch("app.api.channels.meta.register_phone_number", AsyncMock()) as register, \
|
|
patch("app.api.channels.meta.subscribe_app", AsyncMock()):
|
|
r = client.post(self.ES, json={**self.PAYLOAD, "phone_number_id": "PN_VICTIM"})
|
|
|
|
assert r.status_code == 400
|
|
register.assert_not_awaited()
|
|
assert ChannelAccountRepository(db).get_by_external_id("whatsapp", "PN_VICTIM") is None
|
|
|
|
def test_rejects_a_waba_the_token_cannot_read(self, client, db):
|
|
unreadable = (False, {"error": {"message": "Unsupported get request"}})
|
|
with patch("app.api.channels.meta.exchange_signup_code",
|
|
AsyncMock(return_value=(True, {"access_token": "EAAG-attacker"}))), \
|
|
patch("app.api.channels.meta.graph_get", AsyncMock(return_value=unreadable)), \
|
|
patch("app.api.channels.meta.subscribe_app", AsyncMock()):
|
|
r = client.post(self.ES, json=self.PAYLOAD)
|
|
|
|
assert r.status_code == 400
|
|
assert ChannelAccountRepository(db).get_by_external_id("whatsapp", "PN555") is None
|
|
|
|
def test_rejected_without_config_id(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_CONFIG_ID", "")
|
|
exchange = AsyncMock()
|
|
with patch("app.api.channels.meta.exchange_signup_code", exchange):
|
|
r = client.post(self.ES, json=self.PAYLOAD)
|
|
|
|
assert r.status_code == 403
|
|
exchange.assert_not_awaited()
|
|
|
|
def test_manual_reconnect_keeps_the_signup_pin_and_waba(self, client, db):
|
|
"""A reconnect that omits optional credentials must not drop them: the
|
|
PIN cannot be re-read from Meta, and losing waba_id breaks templates."""
|
|
with patch("app.api.channels.meta.exchange_signup_code",
|
|
AsyncMock(return_value=(True, {"access_token": "EAAG-business"}))), \
|
|
patch("app.api.channels.meta.register_phone_number",
|
|
AsyncMock(return_value=(True, {"success": True}))), \
|
|
patch("app.api.channels.meta.graph_get", AsyncMock(return_value=self.WABA_NUMBERS)), \
|
|
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)):
|
|
client.post(self.ES, json=self.PAYLOAD)
|
|
|
|
repo = ChannelAccountRepository(db)
|
|
pin = repo.get_credentials(repo.get_by_external_id("whatsapp", "PN555"))["verification_pin"]
|
|
|
|
# Now reconnect manually, entering only a fresh token
|
|
with patch("app.api.channels.meta.graph_get",
|
|
AsyncMock(return_value=(True, {"verified_name": "Acme"}))), \
|
|
patch("app.api.channels.meta.subscribe_app", AsyncMock(return_value=True)):
|
|
r = client.post(f"{BASE}/whatsapp", json={
|
|
"phone_number_id": "PN555", "access_token": "EAAG-rotated"})
|
|
|
|
assert r.status_code == 200
|
|
credentials = repo.get_credentials(repo.get_by_external_id("whatsapp", "PN555"))
|
|
assert credentials["access_token"] == "EAAG-rotated"
|
|
assert credentials["verification_pin"] == pin
|
|
assert credentials["waba_id"] == "WABA9"
|
|
|
|
|
|
class TestMessengerSignup:
|
|
"""Facebook Login for Business: the code exchanges to a user token, we list
|
|
the customer's Pages, and connect the one they pick. The Page tokens ride
|
|
back to step 2 sealed in signup_token, never through the browser."""
|
|
|
|
PAGES = f"{BASE}/messenger/signup/pages"
|
|
CONNECT = f"{BASE}/messenger/signup/connect"
|
|
|
|
# As /me/accounts lists them for the exchanged user token
|
|
TWO_PAGES = [
|
|
{"id": "PAGE1", "name": "Acme Support", "access_token": "EAAG-page1"},
|
|
{"id": "PAGE2", "name": "Acme Sales", "access_token": "EAAG-page2"},
|
|
]
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def enabled(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_MESSENGER_CONFIG_ID", "MCFG")
|
|
|
|
def _list_pages(self, client, pages=None,
|
|
long_lived=(True, {"access_token": "LONGLIVED"})):
|
|
"""Drive step 1, returning (response, graph_list_all mock)."""
|
|
lister = AsyncMock(return_value=(True, self.TWO_PAGES if pages is None else pages))
|
|
with patch("app.api.channels.meta.exchange_signup_code",
|
|
AsyncMock(return_value=(True, {"access_token": "USERTOKEN"}))), \
|
|
patch("app.api.channels.meta.exchange_for_long_lived_token",
|
|
AsyncMock(return_value=long_lived)), \
|
|
patch("app.api.channels.meta.graph_list_all", lister):
|
|
r = client.post(self.PAGES, json={
|
|
"code": "FB-code", "redirect_uri": "https://app.test/meta-oauth-callback.html"})
|
|
return r, lister
|
|
|
|
def _signup_token(self, client):
|
|
return self._list_pages(client)[0].json()["signup_token"]
|
|
|
|
def test_lists_pages_without_leaking_their_tokens(self, client):
|
|
r, _ = self._list_pages(client)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert [p["id"] for p in body["pages"]] == ["PAGE1", "PAGE2"]
|
|
assert all(set(p.keys()) == {"id", "name"} for p in body["pages"])
|
|
# The whole point of the sealed token: no page token in the wire response
|
|
assert "EAAG-page1" not in r.text and "EAAG-page2" not in r.text
|
|
|
|
def test_connect_stores_the_token_meta_gave_us_for_that_page(self, client, db):
|
|
token = self._signup_token(client)
|
|
subscribe = AsyncMock(return_value=True)
|
|
with patch("app.api.channels.meta.subscribe_app", subscribe):
|
|
r = client.post(self.CONNECT, json={"signup_token": token, "page_id": "PAGE2"})
|
|
|
|
assert r.status_code == 200
|
|
assert r.json()["display_name"] == "Acme Sales"
|
|
repo = ChannelAccountRepository(db)
|
|
account = repo.get_by_external_id("messenger", "PAGE2")
|
|
assert repo.get_credentials(account)["access_token"] == "EAAG-page2"
|
|
subscribe.assert_awaited_once_with(
|
|
"PAGE2", "EAAG-page2", subscribed_fields="messages,messaging_postbacks")
|
|
|
|
def test_rejects_a_page_that_was_not_in_the_signup(self, client, db):
|
|
"""page_id is a selector into a list we fetched, not a claim — a Page the
|
|
signup never offered cannot be connected, and its token is unknown to us."""
|
|
token = self._signup_token(client)
|
|
with patch("app.api.channels.meta.subscribe_app", AsyncMock()) as subscribe:
|
|
r = client.post(self.CONNECT, json={"signup_token": token, "page_id": "PAGE_I_DO_NOT_OWN"})
|
|
|
|
assert r.status_code == 400
|
|
subscribe.assert_not_awaited()
|
|
assert ChannelAccountRepository(db).get_by_external_id("messenger", "PAGE_I_DO_NOT_OWN") is None
|
|
|
|
def test_rejects_another_orgs_signup_token(self, client, db):
|
|
"""A blob sealed for one org must not connect under another — it is bound
|
|
to the org id inside the ciphertext, not just handed to the browser."""
|
|
foreign = _seal_signup_pages(uuid4(), self.TWO_PAGES)
|
|
r = client.post(self.CONNECT, json={"signup_token": foreign, "page_id": "PAGE1"})
|
|
|
|
assert r.status_code == 400
|
|
assert ChannelAccountRepository(db).get_by_external_id("messenger", "PAGE1") is None
|
|
|
|
def test_rejects_an_expired_signup_token(self, client, monkeypatch):
|
|
monkeypatch.setattr("app.api.channels.meta.SIGNUP_TOKEN_TTL_SECONDS", -10)
|
|
token = self._signup_token(client)
|
|
r = client.post(self.CONNECT, json={"signup_token": token, "page_id": "PAGE1"})
|
|
assert r.status_code == 400
|
|
|
|
def test_rejects_a_forged_signup_token(self, client):
|
|
"""A garbage token is a clean 400, not a 500 — decrypt's ValueError is caught."""
|
|
r = client.post(self.CONNECT, json={"signup_token": "not-a-real-token", "page_id": "PAGE1"})
|
|
assert r.status_code == 400
|
|
|
|
def test_no_pages_is_a_clear_400(self, client):
|
|
r, _ = self._list_pages(client, pages=[])
|
|
assert r.status_code == 400
|
|
assert "No Facebook Pages" in r.json()["detail"]
|
|
|
|
def test_stale_code_is_400(self, client):
|
|
with patch("app.api.channels.meta.exchange_signup_code",
|
|
AsyncMock(return_value=(False, {"error": {"message": "Code expired"}}))):
|
|
r = client.post(self.PAGES, json={
|
|
"code": "FB-code", "redirect_uri": "https://app.test/meta-oauth-callback.html"})
|
|
assert r.status_code == 400
|
|
assert r.json()["detail"] == "Code expired"
|
|
|
|
def test_signup_uses_the_long_lived_token_for_page_lookup(self, client):
|
|
"""Page tokens inherit the user token's life, so the lookup must use the
|
|
extended token — otherwise every Page dies about an hour after launch."""
|
|
_, lister = self._list_pages(client)
|
|
assert lister.await_args.args[0] == "me/accounts"
|
|
assert lister.await_args.args[1] == "LONGLIVED"
|
|
|
|
def test_pages_still_listed_when_extension_fails(self, client):
|
|
r, lister = self._list_pages(client, long_lived=(False, {"error": {"message": "nope"}}))
|
|
assert r.status_code == 200
|
|
assert lister.await_args.args[1] == "USERTOKEN"
|
|
|
|
def test_rejected_without_messenger_config_id(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "META_MESSENGER_CONFIG_ID", "")
|
|
exchange = AsyncMock()
|
|
with patch("app.api.channels.meta.exchange_signup_code", exchange):
|
|
r = client.post(self.PAGES, json={
|
|
"code": "FB-code", "redirect_uri": "https://app.test/meta-oauth-callback.html"})
|
|
assert r.status_code == 403
|
|
exchange.assert_not_awaited()
|
|
|
|
|
|
class TestInstagramLogin:
|
|
"""Instagram Login needs no Facebook Page: the business signs in with
|
|
Instagram, so one account comes back and there is nothing to pick."""
|
|
|
|
CONNECT = f"{BASE}/instagram/login/connect"
|
|
PAYLOAD = {"code": "IG-code", "redirect_uri": "https://app.test/meta-oauth-callback.html"}
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def enabled(self, monkeypatch):
|
|
monkeypatch.setattr(settings, "INSTAGRAM_APP_ID", "IGAPP")
|
|
monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "IGSECRET")
|
|
|
|
def _connect(self, client, exchange=None, long_lived=(True, {"access_token": "IGLong"})):
|
|
exchange = exchange or AsyncMock(
|
|
return_value=(True, {"access_token": "IGShort", "user_id": "17841400"}))
|
|
with patch("app.api.channels.meta.exchange_instagram_code", exchange), \
|
|
patch("app.api.channels.meta.exchange_instagram_long_lived",
|
|
AsyncMock(return_value=long_lived)), \
|
|
patch("app.api.channels.meta.graph_get",
|
|
AsyncMock(return_value=(True, {"username": "acme"}))), \
|
|
patch("app.api.channels.meta.subscribe_instagram_app",
|
|
AsyncMock(return_value=True)):
|
|
return client.post(self.CONNECT, json=self.PAYLOAD), exchange
|
|
|
|
def test_connects_the_account_that_logged_in(self, client, db):
|
|
r, exchange = self._connect(client)
|
|
|
|
assert r.status_code == 200
|
|
assert r.json()["display_name"] == "@acme"
|
|
repo = ChannelAccountRepository(db)
|
|
account = repo.get_by_external_id("instagram", "17841400")
|
|
assert account is not None
|
|
# The long-lived token is what gets stored, not the ~1h login token.
|
|
assert repo.get_credentials(account)["access_token"] == "IGLong"
|
|
exchange.assert_awaited_once_with("IG-code", "https://app.test/meta-oauth-callback.html")
|
|
|
|
def test_keys_the_account_on_the_id_webhooks_route_by(self, client, db):
|
|
"""/me's user_id is the professional account id that arrives as the
|
|
webhook's entry.id; the login response's can be app-scoped. Keying on
|
|
the wrong one drops every inbound DM silently."""
|
|
with patch("app.api.channels.meta.exchange_instagram_code",
|
|
AsyncMock(return_value=(True, {"access_token": "IGShort", "user_id": "APPSCOPED"}))), \
|
|
patch("app.api.channels.meta.exchange_instagram_long_lived",
|
|
AsyncMock(return_value=(True, {"access_token": "IGLong"}))), \
|
|
patch("app.api.channels.meta.graph_get",
|
|
AsyncMock(return_value=(True, {"user_id": "17841400", "username": "acme"}))), \
|
|
patch("app.api.channels.meta.subscribe_instagram_app", AsyncMock(return_value=True)):
|
|
r = client.post(self.CONNECT, json=self.PAYLOAD)
|
|
|
|
assert r.status_code == 200
|
|
repo = ChannelAccountRepository(db)
|
|
assert repo.get_by_external_id("instagram", "17841400") is not None
|
|
assert repo.get_by_external_id("instagram", "APPSCOPED") is None
|
|
|
|
def test_accepts_the_wrapped_token_response_shape(self, client, db):
|
|
"""Instagram also returns the token inside a `data` list."""
|
|
wrapped = AsyncMock(return_value=(True, {
|
|
"data": [{"access_token": "IGShort", "user_id": "17841400"}]}))
|
|
r, _ = self._connect(client, exchange=wrapped)
|
|
|
|
assert r.status_code == 200
|
|
assert ChannelAccountRepository(db).get_by_external_id("instagram", "17841400") is not None
|
|
|
|
def test_falls_back_to_the_short_token_when_extension_fails(self, client, db):
|
|
r, _ = self._connect(client, long_lived=(False, {"error": {"message": "nope"}}))
|
|
|
|
assert r.status_code == 200
|
|
repo = ChannelAccountRepository(db)
|
|
account = repo.get_by_external_id("instagram", "17841400")
|
|
assert repo.get_credentials(account)["access_token"] == "IGShort"
|
|
|
|
def test_stale_code_is_400(self, client, db):
|
|
stale = AsyncMock(return_value=(False, {"error": {"message": "Code expired"}}))
|
|
r, _ = self._connect(client, exchange=stale)
|
|
|
|
assert r.status_code == 400
|
|
assert r.json()["detail"] == "Code expired"
|
|
assert ChannelAccountRepository(db).get_by_external_id("instagram", "17841400") is None
|
|
|
|
def test_a_response_without_a_token_is_400(self, client):
|
|
empty = AsyncMock(return_value=(True, {}))
|
|
r, _ = self._connect(client, exchange=empty)
|
|
assert r.status_code == 400
|
|
|
|
def test_rejected_without_instagram_app_credentials(self, client, monkeypatch):
|
|
monkeypatch.setattr(settings, "INSTAGRAM_APP_ID", "")
|
|
exchange = AsyncMock()
|
|
with patch("app.api.channels.meta.exchange_instagram_code", exchange):
|
|
r = client.post(self.CONNECT, json=self.PAYLOAD)
|
|
assert r.status_code == 403
|
|
exchange.assert_not_awaited()
|
|
|
|
def test_config_serves_the_instagram_app_id_and_no_config_id(self, client, monkeypatch):
|
|
"""The frontend builds the Instagram authorize URL from the Instagram
|
|
app id; the Facebook config id is meaningless here."""
|
|
monkeypatch.setattr(settings, "META_APP_ID", "FBAPP")
|
|
body = client.get(f"{BASE}/embedded-signup-config?channel=instagram").json()
|
|
assert (body["enabled"], body["app_id"], body["config_id"]) == (True, "IGAPP", None)
|
|
|
|
|
|
class TestGraphErrorDetail:
|
|
"""Meta's `message` is often a generic OAuth string; error_user_msg is the
|
|
one that names the actual asset and rule.
|
|
|
|
Tested directly rather than through an endpoint: it is a pure function used
|
|
by every Graph-facing route, and pinning it to whichever route happens to
|
|
exist is what made this class need rewriting when template delete went away.
|
|
"""
|
|
|
|
# A real Graph body, kept for its shape: a generic OAuth `message` paired
|
|
# with a specific error_user_msg.
|
|
UNVERIFIED = {"error": {
|
|
"message": "Application does not have permission for this action",
|
|
"code": 10,
|
|
"type": "OAuthException",
|
|
"error_subcode": 2388185,
|
|
"error_user_title": "Cannot create message template",
|
|
"error_user_msg": "This WhatsApp Business account does not have permission "
|
|
"to create message template",
|
|
}}
|
|
|
|
def test_prefers_metas_human_readable_reason(self):
|
|
from app.channels.meta_base import graph_detail
|
|
|
|
# Not the useless "Application does not have permission for this action"
|
|
assert graph_detail(self.UNVERIFIED, "fallback") == (
|
|
"This WhatsApp Business account does not have permission to create message template"
|
|
)
|
|
|
|
def test_falls_back_to_message_when_there_is_no_user_msg(self):
|
|
from app.channels.meta_base import graph_detail
|
|
|
|
assert graph_detail({"error": {"message": "Invalid parameter"}}, "fallback") == (
|
|
"Invalid parameter")
|
|
|
|
def test_falls_back_to_our_own_text_when_graph_says_nothing(self):
|
|
from app.channels.meta_base import graph_detail
|
|
|
|
assert graph_detail({}, "Could not read templates") == "Could not read templates"
|
|
|
|
def test_survives_an_error_that_is_not_an_object(self):
|
|
from app.channels.meta_base import graph_detail
|
|
|
|
assert graph_detail({"error": "boom"}, "fallback") == "fallback"
|
|
|
|
|
|
class TestTemplateLibraryLink:
|
|
"""Templates are written in Meta's Template Library, so the UI needs a link
|
|
into it scoped to this number's Business Account."""
|
|
|
|
def _url(self, client, account):
|
|
return client.get(f"{BASE}/whatsapp/{account.id}/template-library")
|
|
|
|
OWNER = (True, {"owner_business_info": {"id": "BIZ1", "name": "Acme"}})
|
|
|
|
def test_links_to_the_library_for_this_waba_and_business(self, client, waba_account):
|
|
with patch("app.api.channels.whatsapp_messaging.graph_get", AsyncMock(return_value=self.OWNER)):
|
|
r = self._url(client, waba_account)
|
|
|
|
assert r.status_code == 200
|
|
url = r.json()["url"]
|
|
assert url.startswith("https://business.facebook.com/latest/whatsapp_manager/template_library")
|
|
assert "asset_id=WABA9" in url
|
|
assert "business_id=BIZ1" in url
|
|
|
|
def test_still_links_when_the_business_cannot_be_read(self, client, waba_account):
|
|
# A worse link, not a dead button: Meta resolves the page from asset_id.
|
|
with patch("app.api.channels.whatsapp_messaging.graph_get",
|
|
AsyncMock(return_value=(False, {"error": {"message": "nope"}}))):
|
|
r = self._url(client, waba_account)
|
|
|
|
assert r.status_code == 200
|
|
assert "asset_id=WABA9" in r.json()["url"]
|
|
assert "business_id" not in r.json()["url"]
|
|
|
|
def test_omits_business_id_when_graph_returns_none(self, client, waba_account):
|
|
with patch("app.api.channels.whatsapp_messaging.graph_get", AsyncMock(return_value=(True, {}))):
|
|
r = self._url(client, waba_account)
|
|
|
|
assert "business_id" not in r.json()["url"]
|
|
|
|
def test_requires_waba_id(self, client, whatsapp_account):
|
|
r = self._url(client, whatsapp_account)
|
|
assert r.status_code == 400
|
|
|
|
def test_rejects_other_orgs_account(self, client, db, waba_account):
|
|
waba_account.organization_id = uuid4()
|
|
db.commit()
|
|
assert self._url(client, waba_account).status_code == 404
|
|
|
|
|
|
class TestOutboundConversation:
|
|
"""POST /whatsapp/{account_id}/conversations — start a conversation from a
|
|
phone number. The identity, window and rollback rules live here."""
|
|
|
|
URL = lambda self, a: f"{BASE}/whatsapp/{a.id}/conversations"
|
|
|
|
APPROVED_UTILITY = [{
|
|
"name": "order_update", "language": "en_US", "status": "APPROVED",
|
|
"category": "UTILITY",
|
|
"components": [{"type": "BODY", "text": "Hi {{1}}, your order {{2}} shipped."}],
|
|
}]
|
|
|
|
BODY_PARAMS = [{"type": "body", "parameters": [
|
|
{"type": "text", "text": "Priya"}, {"type": "text", "text": "A-12"}]}]
|
|
|
|
@pytest.fixture
|
|
def routed(self, db, waba_account, test_agent):
|
|
from app.repositories.channels import AgentChannelConfigRepository
|
|
AgentChannelConfigRepository(db).set_agent(waba_account.id, test_agent.id)
|
|
return waba_account
|
|
|
|
def _send(self, client, account, templates=None, send_ok=True, **overrides):
|
|
from unittest.mock import MagicMock
|
|
payload = {"to": "+91 12345 67890", "template_name": "order_update",
|
|
"language": "en_US", "components": self.BODY_PARAMS, **overrides}
|
|
adapter = MagicMock()
|
|
adapter.send_template = AsyncMock(return_value=SendResult(
|
|
ok=send_ok, external_message_id="wamid.OUT1" if send_ok else None,
|
|
error=None if send_ok else "Recipient not on WhatsApp"))
|
|
with patch("app.services.whatsapp_outbound.fetch_message_templates",
|
|
AsyncMock(return_value=(True, templates or self.APPROVED_UTILITY))), \
|
|
patch("app.services.whatsapp_outbound.get_adapter", lambda _: adapter):
|
|
return client.post(self.URL(account), json=payload), adapter
|
|
|
|
def test_a_refused_token_on_the_template_check_marks_the_number(self, client, db, routed):
|
|
refused = {"error": {"message": "Error validating access token", "code": 190}}
|
|
with patch("app.services.whatsapp_outbound.fetch_message_templates",
|
|
AsyncMock(return_value=(False, refused))):
|
|
r = client.post(self.URL(routed), json={
|
|
"to": "+91 12345 67890", "template_name": "order_update",
|
|
"language": "en_US", "components": self.BODY_PARAMS})
|
|
assert r.status_code == 502
|
|
db.refresh(routed)
|
|
assert needs_reconnect(routed) is True
|
|
|
|
def test_a_refused_token_on_the_send_marks_the_number(self, client, db, routed):
|
|
from unittest.mock import MagicMock
|
|
adapter = MagicMock()
|
|
adapter.send_template = AsyncMock(return_value=SendResult(
|
|
ok=False, error="Authentication Error", auth_failed=True))
|
|
with patch("app.services.whatsapp_outbound.fetch_message_templates",
|
|
AsyncMock(return_value=(True, self.APPROVED_UTILITY))), \
|
|
patch("app.services.whatsapp_outbound.get_adapter", lambda _: adapter):
|
|
r = client.post(self.URL(routed), json={
|
|
"to": "+91 12345 67890", "template_name": "order_update",
|
|
"language": "en_US", "components": self.BODY_PARAMS})
|
|
assert r.status_code == 502
|
|
db.refresh(routed)
|
|
assert needs_reconnect(routed) is True
|
|
|
|
def test_creates_person_session_and_windowless_conversation(self, client, db, routed):
|
|
from app.models.customer import Customer
|
|
from app.models.chat_history import ChatHistory
|
|
from app.repositories.channels import ChannelConversationRepository
|
|
|
|
r, adapter = self._send(client, routed, customer_name="Priya")
|
|
|
|
assert r.status_code == 200
|
|
session_id = r.json()["session_id"]
|
|
|
|
customer = db.query(Customer).filter(Customer.phone == "+911234567890").one()
|
|
assert customer.full_name == "Priya"
|
|
assert customer.email == "911234567890@whatsapp.channel"
|
|
assert customer.lead_source == {"channel": "whatsapp", "via": "outbound"}
|
|
|
|
conversation = ChannelConversationRepository(db).get_by_session(session_id)
|
|
# The window lie: no inbound message means no open 24h window.
|
|
assert conversation.last_inbound_at is None
|
|
assert conversation.extra["last_business_message"]["text"] == "Hi Priya, your order A-12 shipped."
|
|
assert conversation.extra["last_business_message"]["external_message_id"] == "wamid.OUT1"
|
|
|
|
from uuid import UUID as _UUID
|
|
row = db.query(ChatHistory).filter(
|
|
ChatHistory.session_id == _UUID(session_id)).one()
|
|
assert row.message == "Hi Priya, your order A-12 shipped."
|
|
assert row.message_type == "bot"
|
|
assert row.attributes["outbound_template"] == "order_update"
|
|
|
|
adapter.send_template.assert_awaited_once()
|
|
|
|
def test_template_row_records_its_message_id(self, client, db, routed):
|
|
"""Receipts for an outbound template find the row it was stored as."""
|
|
from uuid import UUID as _UUID
|
|
from app.models.chat_history import ChatHistory
|
|
|
|
r, _ = self._send(client, routed, customer_name="Priya")
|
|
|
|
row = db.query(ChatHistory).filter(
|
|
ChatHistory.session_id == _UUID(r.json()["session_id"])).one()
|
|
assert row.external_message_id == "wamid.OUT1"
|
|
|
|
def test_template_send_is_announced_as_the_inbox_users(self, client, db, routed):
|
|
from app.channels.app_hooks import EventKind, register_event_listener
|
|
from app.channels.constants import MessageSource
|
|
seen = []
|
|
|
|
async def listen(event):
|
|
seen.append(event)
|
|
register_event_listener(listen)
|
|
|
|
with patch("app.services.chat_notifications.notify_new_chat", AsyncMock()) as notify:
|
|
r, _ = self._send(client, routed, customer_name="Priya")
|
|
|
|
assert r.status_code == 200
|
|
(event,) = seen
|
|
assert event.kind == EventKind.OUTBOUND
|
|
assert event.outbound.source == MessageSource.HUMAN
|
|
assert event.outbound.external_message_id == "wamid.OUT1"
|
|
notify.assert_not_awaited() # the sender knows; the reply will notify
|
|
|
|
def test_picked_person_at_someone_elses_number_is_refused(self, client, db, routed):
|
|
"""`to` and `customer_id` arrive as independent fields. Binding Alice to
|
|
Bob's number is unrecoverable: Bob's reply resolves BY PHONE to Bob, so
|
|
history splits and the thread never appears on the profile it claims."""
|
|
from app.repositories.customer import CustomerRepository
|
|
repo = CustomerRepository(db)
|
|
alice = repo.create_customer(email="alice@example.com",
|
|
organization_id=routed.organization_id,
|
|
full_name="Alice")
|
|
repo.create_customer(email="bob@example.com",
|
|
organization_id=routed.organization_id,
|
|
full_name="Bob", phone="+911234567890")
|
|
|
|
r, adapter = self._send(client, routed, customer_id=str(alice.id))
|
|
|
|
assert r.status_code == 400
|
|
assert "already belongs to Bob" in r.json()["detail"]
|
|
adapter.send_template.assert_not_awaited()
|
|
|
|
def test_picked_person_with_a_different_number_is_refused(self, client, db, routed):
|
|
"""Their own number is on file and it isn't this one — silently sending
|
|
anyway is how a person acquires a second, unlinked identity."""
|
|
from app.repositories.customer import CustomerRepository
|
|
alice = CustomerRepository(db).create_customer(
|
|
email="alice@example.com", organization_id=routed.organization_id,
|
|
full_name="Alice", phone="+911111111111")
|
|
|
|
r, adapter = self._send(client, routed, customer_id=str(alice.id))
|
|
|
|
assert r.status_code == 400
|
|
assert "different number on file" in r.json()["detail"]
|
|
adapter.send_template.assert_not_awaited()
|
|
|
|
def test_picked_person_without_a_number_adopts_the_typed_one(self, client, db, routed):
|
|
"""The drawer's real use: message a known person at a number you type.
|
|
No conflict, so it binds and backfills — no duplicate row."""
|
|
from app.models.customer import Customer
|
|
from app.repositories.customer import CustomerRepository
|
|
alice = CustomerRepository(db).create_customer(
|
|
email="alice@example.com", organization_id=routed.organization_id,
|
|
full_name="Alice")
|
|
|
|
r, _ = self._send(client, routed, customer_id=str(alice.id))
|
|
|
|
assert r.status_code == 200
|
|
db.refresh(alice)
|
|
assert alice.phone == "+911234567890"
|
|
# And no second person was minted for the number.
|
|
assert db.query(Customer).filter(
|
|
Customer.phone == "+911234567890").count() == 1
|
|
|
|
def test_window_reports_expired_until_they_reply(self, client, db, routed):
|
|
from app.channels import get_adapter
|
|
from app.channels.base import WindowStatus
|
|
from app.repositories.channels import ChannelConversationRepository
|
|
|
|
r, _ = self._send(client, routed)
|
|
conversation = ChannelConversationRepository(db).get_by_session(r.json()["session_id"])
|
|
assert get_adapter("whatsapp").check_delivery_window(conversation) \
|
|
== WindowStatus.TEMPLATE_REQUIRED
|
|
|
|
def test_second_send_reuses_the_open_session(self, client, db, routed):
|
|
r1, _ = self._send(client, routed)
|
|
r2, _ = self._send(client, routed)
|
|
assert r1.json()["session_id"] == r2.json()["session_id"]
|
|
|
|
def test_attaches_to_an_existing_person_by_phone(self, client, db, routed, test_organization):
|
|
from app.models.customer import Customer
|
|
from app.repositories.customer import CustomerRepository
|
|
existing = CustomerRepository(db).create_customer(
|
|
email="priya@example.com", organization_id=test_organization.id,
|
|
full_name="Priya", phone="+911234567890")
|
|
|
|
r, _ = self._send(client, routed)
|
|
|
|
assert r.status_code == 200
|
|
# No junk row: the conversation belongs to the person who owns the phone.
|
|
assert db.query(Customer).filter(Customer.phone == "+911234567890").count() == 1
|
|
from app.repositories.channels import ChannelConversationRepository
|
|
conversation = ChannelConversationRepository(db).get_by_session(r.json()["session_id"])
|
|
assert conversation.customer_id == existing.id
|
|
|
|
def test_failed_send_leaves_no_empty_thread(self, client, db, routed):
|
|
from app.models.channels import ChannelConversation
|
|
from app.models.session_to_agent import SessionToAgent
|
|
|
|
r, _ = self._send(client, routed, send_ok=False)
|
|
|
|
assert r.status_code == 502
|
|
assert "Recipient not on WhatsApp" in r.json()["detail"]
|
|
assert db.query(ChannelConversation).filter(
|
|
ChannelConversation.external_conversation_id == "911234567890").count() == 0
|
|
assert db.query(SessionToAgent).count() == 0
|
|
|
|
def test_marketing_templates_cannot_start_conversations(self, client, routed):
|
|
marketing = [{**self.APPROVED_UTILITY[0], "category": "MARKETING"}]
|
|
r, _ = self._send(client, routed, templates=marketing)
|
|
assert r.status_code == 400
|
|
assert "Marketing" in r.json()["detail"]
|
|
|
|
def test_marketing_is_allowed_once_a_conversation_exists(self, client, db, routed):
|
|
"""The Utility/Auth rule is about business-INITIATED contact. Reaching
|
|
an open conversation is the reopen case — the customer did message us,
|
|
which is why Meta accepts Marketing there and why the inbox picker has
|
|
never been gated. Gating it here refused a legal send, and made the
|
|
same template succeed from the inbox and fail from the modal."""
|
|
marketing = [{**self.APPROVED_UTILITY[0], "category": "MARKETING"}]
|
|
|
|
first, _ = self._send(client, routed) # Utility opens the thread
|
|
assert first.status_code == 200
|
|
|
|
second, adapter = self._send(client, routed, templates=marketing)
|
|
|
|
assert second.status_code == 200
|
|
assert second.json()["session_id"] == first.json()["session_id"]
|
|
adapter.send_template.assert_awaited_once()
|
|
|
|
def test_unapproved_template_is_refused(self, client, routed):
|
|
pending = [{**self.APPROVED_UTILITY[0], "status": "PENDING"}]
|
|
r, _ = self._send(client, routed, templates=pending)
|
|
assert r.status_code == 400
|
|
assert "not approved" in r.json()["detail"]
|
|
|
|
def test_requires_a_routed_agent(self, client, waba_account):
|
|
r, _ = self._send(client, waba_account) # nothing routed
|
|
assert r.status_code == 400
|
|
assert "Route an agent" in r.json()["detail"]
|
|
|
|
def test_rejects_a_number_without_country_code(self, client, routed):
|
|
r, _ = self._send(client, routed, to="1234567890")
|
|
assert r.status_code == 400
|
|
assert "international format" in r.json()["detail"]
|
|
|
|
def test_rejects_other_orgs_account(self, client, db, routed):
|
|
routed.organization_id = uuid4()
|
|
db.commit()
|
|
r, _ = self._send(client, routed)
|
|
assert r.status_code == 404
|
|
|
|
|
|
class TestInboxAgentCanActuallyReachTheFeature:
|
|
"""A support agent with ONLY view_all_chats — no manage_organization.
|
|
|
|
Every other test in this file runs as an admin who was also given
|
|
view_all_chats, so none of them could observe that the endpoints the
|
|
picker READS were still admin-only while the sends they feed had been
|
|
loosened. That mismatch made the whole feature unreachable for exactly
|
|
the role it was opened up for.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def inbox_client(self, db, test_organization, waba_account, test_agent, monkeypatch):
|
|
from app.models.permission import Permission
|
|
from app.models.role import Role
|
|
from app.models.user import User
|
|
from app.core.security import get_password_hash
|
|
from app.repositories.channels import AgentChannelConfigRepository
|
|
|
|
monkeypatch.setattr(settings, "META_APP_SECRET", APP_SECRET)
|
|
AgentChannelConfigRepository(db).set_agent(waba_account.id, test_agent.id)
|
|
|
|
role = Role(name="Support Agent", organization_id=test_organization.id)
|
|
role.permissions = [Permission(name="view_all_chats")]
|
|
db.add(role); db.commit(); db.refresh(role)
|
|
agent_user = User(
|
|
id=uuid4(), organization_id=test_organization.id,
|
|
email="support@example.com", full_name="Support",
|
|
hashed_password=get_password_hash("pw"), is_active=True,
|
|
role_id=role.id)
|
|
db.add(agent_user); db.commit()
|
|
|
|
app.dependency_overrides[get_current_user] = lambda: agent_user
|
|
app.dependency_overrides[get_current_organization] = lambda: test_organization
|
|
app.dependency_overrides[get_db] = lambda: (yield db)
|
|
yield TestClient(app)
|
|
app.dependency_overrides.clear()
|
|
|
|
def test_can_list_the_templates_the_picker_needs(self, inbox_client, waba_account):
|
|
templates = [{"name": "order_update", "language": "en_US",
|
|
"status": "APPROVED", "category": "UTILITY",
|
|
"components": [{"type": "BODY", "text": "Hi {{1}}"}]}]
|
|
with patch("app.api.channels.whatsapp_messaging.fetch_message_templates",
|
|
AsyncMock(return_value=(True, templates))):
|
|
r = inbox_client.get(f"{BASE}/whatsapp/{waba_account.id}/templates")
|
|
assert r.status_code == 200
|
|
assert r.json()[0]["name"] == "order_update"
|
|
|
|
def test_a_refused_token_marks_the_number_for_reconnection(self, inbox_client, db, waba_account):
|
|
refused = {"error": {"message": "Authentication Error", "code": 0}}
|
|
with patch("app.api.channels.whatsapp_messaging.fetch_message_templates",
|
|
AsyncMock(return_value=(False, refused))):
|
|
r = inbox_client.get(f"{BASE}/whatsapp/{waba_account.id}/templates")
|
|
assert r.status_code == 502
|
|
db.refresh(waba_account)
|
|
assert needs_reconnect(waba_account) is True
|
|
|
|
accounts = inbox_client.get("/api/v1/channels/accounts").json()
|
|
assert next(a for a in accounts if a["id"] == str(waba_account.id))["needs_reconnect"] is True
|
|
|
|
def test_other_template_read_failures_do_not(self, inbox_client, db, waba_account):
|
|
with patch("app.api.channels.whatsapp_messaging.fetch_message_templates",
|
|
AsyncMock(return_value=(False, {"error": {"message": "Unsupported get", "code": 100}}))):
|
|
r = inbox_client.get(f"{BASE}/whatsapp/{waba_account.id}/templates")
|
|
assert r.status_code == 502
|
|
db.refresh(waba_account)
|
|
assert needs_reconnect(waba_account) is False
|
|
|
|
def test_can_see_the_accounts_that_gate_the_new_conversation_button(
|
|
self, inbox_client, waba_account):
|
|
r = inbox_client.get("/api/v1/channels/accounts")
|
|
assert r.status_code == 200
|
|
assert any(a["channel_type"] == "whatsapp" for a in r.json())
|
|
|
|
def test_still_cannot_reach_the_template_library_link(self, inbox_client, waba_account):
|
|
"""Loosened for reading and sending, not for authoring: the route into
|
|
WhatsApp Manager, where templates are written, stays org-admin."""
|
|
r = inbox_client.get(f"{BASE}/whatsapp/{waba_account.id}/template-library")
|
|
assert r.status_code == 403
|
|
|
|
def test_a_view_only_role_cannot_send(self, inbox_client, waba_account):
|
|
"""Reading the picker is not permission to fire a billable message.
|
|
|
|
This role holds view_all_chats and nothing else. Sending takes the same
|
|
grant as taking a chat over, so a read-only observer can browse the
|
|
templates without being able to message a customer.
|
|
"""
|
|
r = inbox_client.post(
|
|
f"{BASE}/whatsapp/{waba_account.id}/conversations",
|
|
json={"phone": "+15551234567", "template_name": "order_update",
|
|
"language": "en_US", "body_params": []},
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
def test_an_assigned_chats_agent_can_send(
|
|
self, db, test_organization, waba_account, test_agent, monkeypatch):
|
|
"""manage_assigned_chats — what the seeded Agent role holds — is enough."""
|
|
monkeypatch.setattr(settings, "META_APP_SECRET", APP_SECRET)
|
|
role = Role(name="Sending Agent", organization_id=test_organization.id)
|
|
role.permissions = [
|
|
Permission(name="view_assigned_chats"),
|
|
Permission(name="manage_assigned_chats"),
|
|
]
|
|
db.add(role); db.commit(); db.refresh(role)
|
|
sender = User(
|
|
id=uuid4(), organization_id=test_organization.id,
|
|
email="sender@example.com", full_name="Sender",
|
|
hashed_password=get_password_hash("pw"), is_active=True,
|
|
role_id=role.id)
|
|
db.add(sender); db.commit()
|
|
|
|
app.dependency_overrides[get_current_user] = lambda: sender
|
|
app.dependency_overrides[get_current_organization] = lambda: test_organization
|
|
app.dependency_overrides[get_db] = lambda: (yield db)
|
|
try:
|
|
r = TestClient(app).post(
|
|
f"{BASE}/whatsapp/{waba_account.id}/conversations",
|
|
json={"phone": "+15551234567", "template_name": "nope",
|
|
"language": "en_US", "body_params": []},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
# Past the permission gate — it fails on the unapproved template, not 403.
|
|
assert r.status_code != 403
|
|
|
|
|
|
class TestChannelAccountSecrets:
|
|
"""The webhook URL in a channel account embeds `webhook_secret`, which is
|
|
the only authentication on the inbound email and SMS webhooks. Widening the
|
|
accounts list to inbox agents must not hand that to them."""
|
|
|
|
@pytest.fixture
|
|
def inbox_client(self, db, test_organization, monkeypatch):
|
|
role = Role(name="Inbox Only", organization_id=test_organization.id)
|
|
role.permissions = [Permission(name="view_assigned_chats")]
|
|
db.add(role); db.commit(); db.refresh(role)
|
|
agent_user = User(
|
|
id=uuid4(), organization_id=test_organization.id,
|
|
email="secrets@example.com", full_name="Agent",
|
|
hashed_password=get_password_hash("pw"), is_active=True,
|
|
role_id=role.id)
|
|
db.add(agent_user); db.commit()
|
|
|
|
app.dependency_overrides[get_current_user] = lambda: agent_user
|
|
app.dependency_overrides[get_current_organization] = lambda: test_organization
|
|
app.dependency_overrides[get_db] = lambda: (yield db)
|
|
yield TestClient(app)
|
|
app.dependency_overrides.clear()
|
|
|
|
def test_agent_sees_accounts_without_the_webhook_secret(self, inbox_client, waba_account):
|
|
r = inbox_client.get("/api/v1/channels/accounts")
|
|
|
|
assert r.status_code == 200
|
|
assert r.json(), "the agent should still see the accounts"
|
|
for account in r.json():
|
|
assert account["webhook_url"] is None
|
|
|
|
def test_admin_still_gets_the_webhook_url(self, client, waba_account, db, test_organization):
|
|
db.add(ChannelAccount(
|
|
id=uuid4(), organization_id=test_organization.id,
|
|
channel_type=ChannelType.EMAIL.value, display_name="Support Inbox",
|
|
external_account_id="support@example.com", is_active=True,
|
|
encrypted_credentials="{}", webhook_secret="s3cret"))
|
|
db.commit()
|
|
|
|
r = client.get("/api/v1/channels/accounts")
|
|
|
|
assert r.status_code == 200
|
|
email_account = next(a for a in r.json() if a["channel_type"] == "email")
|
|
assert "s3cret" in email_account["webhook_url"]
|