""" 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"]