From 67b260400a8819691f937fbb44e04b59e2adade6 Mon Sep 17 00:00:00 2001 From: Bhargav Veepuri Date: Tue, 21 Jul 2026 18:19:49 +0000 Subject: [PATCH] fix(whatsapp): end-to-end allowlisted group intake for multi-member chats Wizard/.env installs configure WHATSAPP_ALLOWED_USERS and WHATSAPP_GROUP_ALLOWED_USERS, but three independent gates still dropped messages after the bridge queued them: 1. Baileys adapter only read allowlists from config.extra (empty for env-only installs), so dm_policy/group_policy=allowlist ran with an empty set and silently rejected every inbound. 2. Bridge applied the DM sender allowlist to group participants, so any non-owner member of an allowlisted support group was dropped before Python. 3. Gateway authz treated WHATSAPP_GROUP_ALLOWED_USERS as a sender list (or ignored it), so even adapter-accepted group traffic hit Unauthorized user for members not also on the DM allowlist. Also forwards owner-typed (fromMe) group messages when WHATSAPP_FORWARD_OWNER_MESSAGES is on (personal-number bot) or when mode is self-chat, so operators can talk to the agent in groups. Default OFF / empty-config deny-all semantics preserved. Explicit allow_from: [] does not widen via a stale env allowlist. --- gateway/authz_mixin.py | 29 +++- plugins/platforms/whatsapp/adapter.py | 59 ++++++- scripts/whatsapp-bridge/bridge.js | 54 ++++++- scripts/whatsapp-bridge/dm_allowlist_scope.js | 24 +++ .../dm_allowlist_scope.test.mjs | 48 ++++++ scripts/whatsapp-bridge/from_me_group_gate.js | 55 +++++++ .../from_me_group_gate.test.mjs | 83 ++++++++++ .../test_whatsapp_baileys_allowlist_env.py | 121 +++++++++++++++ .../gateway/test_whatsapp_group_chat_authz.py | 145 ++++++++++++++++++ 9 files changed, 609 insertions(+), 9 deletions(-) create mode 100644 scripts/whatsapp-bridge/dm_allowlist_scope.js create mode 100644 scripts/whatsapp-bridge/dm_allowlist_scope.test.mjs create mode 100644 scripts/whatsapp-bridge/from_me_group_gate.js create mode 100644 scripts/whatsapp-bridge/from_me_group_gate.test.mjs create mode 100644 tests/gateway/test_whatsapp_baileys_allowlist_env.py create mode 100644 tests/gateway/test_whatsapp_group_chat_authz.py diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 884a60948c9c..c1fd815b8c17 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -345,6 +345,13 @@ def _is_user_authorized(self, source: SessionSource) -> bool: chat_allowlist_env = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", + # WhatsApp group allowlist is chat-scoped (group JIDs), same + # shape as Telegram's GROUP_ALLOWED_CHATS. Without this entry, + # messages that already passed the adapter's group_policy still + # fail the gateway user allowlist for non-DM-allowlisted + # senders — so other members' text/voice in an allowlisted + # group is silently unauthorized. + Platform.WHATSAPP: "WHATSAPP_GROUP_ALLOWED_USERS", }.get(source.platform, "") if chat_allowlist_env: raw_chat_allowlist = os.getenv(chat_allowlist_env, "").strip() @@ -354,7 +361,23 @@ def _is_user_authorized(self, source: SessionSource) -> bool: for cid in raw_chat_allowlist.split(",") if cid.strip() } - if "*" in allowed_group_ids or source.chat_id in allowed_group_ids: + # WhatsApp JIDs: accept bare numeric id or full @g.us form. + if source.platform == Platform.WHATSAPP: + bare = source.chat_id.split("@", 1)[0] + expanded = set() + for cid in allowed_group_ids: + expanded.add(cid) + expanded.add(cid.split("@", 1)[0]) + if "@" not in cid: + expanded.add(f"{cid}@g.us") + allowed_group_ids = expanded + if ( + "*" in allowed_group_ids + or source.chat_id in allowed_group_ids + or bare in allowed_group_ids + ): + return True + elif "*" in allowed_group_ids or source.chat_id in allowed_group_ids: return True # Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466). @@ -403,6 +426,10 @@ def _is_user_authorized(self, source: SessionSource) -> bool: platform_group_chat_env_map = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", + # Chat-scoped WhatsApp groups (JIDs). Must match the early + # chat_allowlist_env map above so the later group_chat_allowlist + # branch also authorizes allowlisted @g.us chats. + Platform.WHATSAPP: "WHATSAPP_GROUP_ALLOWED_USERS", } platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 7cf94b7c1e63..a32ce4a40d5f 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -357,6 +357,30 @@ def check_whatsapp_requirements() -> bool: return False +def _resolve_allow_list_source(config_extra: dict, *config_keys: str, env_vars: tuple[str, ...] | list[str]): + """Resolve an allowlist source with explicit-empty-means-deny-all semantics. + + If any of ``config_keys`` is present in ``config_extra`` (even if its + value is an empty list or empty string), that value wins and we never + fall back to the environment. Only when none of the config keys are + present at all do we fall back to the first non-empty env var in + ``env_vars``. This prevents an explicit ``allow_from: []`` in config + from silently widening to a stale environment allowlist. + """ + for key in config_keys: + if key in config_extra: + return config_extra[key] + for env_var in env_vars: + value = os.getenv(env_var) + if value is not None and str(value).strip() != "": + return value + # Last env var wins even if empty so callers can still see "" vs None. + for env_var in env_vars: + if os.getenv(env_var) is not None: + return os.getenv(env_var) + return None + + class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): """ WhatsApp adapter. @@ -407,9 +431,40 @@ def __init__(self, config: PlatformConfig): )) self._reply_prefix: Optional[str] = config.extra.get("reply_prefix") self._dm_policy = str(config.extra.get("dm_policy") or os.getenv("WHATSAPP_DM_POLICY", "pairing")).strip().lower() - self._allow_from = self._coerce_allow_list(config.extra.get("allow_from") or config.extra.get("allowFrom")) + # Prefer PlatformConfig.extra (YAML / plugin bridge), then the documented + # env vars. Without the env fallback, a wizard/.env-only install ends up + # with dm_policy=allowlist and an EMPTY allowlist — every inbound DM and + # (with group_policy=allowlist) every group message is silently dropped + # after the bridge has already queued it. Mirrors WhatsApp Cloud + # (WHATSAPP_CLOUD_ALLOWED_USERS) and the documented WHATSAPP_* vars. + # Key-presence semantics: explicit empty config stays deny-all. + self._allow_from = self._coerce_allow_list( + _resolve_allow_list_source( + config.extra, + "allow_from", + "allowFrom", + env_vars=("WHATSAPP_ALLOWED_USERS", "WHATSAPP_ALLOW_FROM"), + ) + ) + # WHATSAPP_ALLOW_ALL_USERS=* / true is the documented open-DM opt-in. + # Only apply when no allowlist source was configured at all. + _allow_all = (os.getenv("WHATSAPP_ALLOW_ALL_USERS") or "").strip().lower() + if ( + not self._allow_from + and "allow_from" not in config.extra + and "allowFrom" not in config.extra + and _allow_all in {"true", "1", "yes", "on", "*"} + ): + self._allow_from = {"*"} self._group_policy = str(config.extra.get("group_policy") or os.getenv("WHATSAPP_GROUP_POLICY", "pairing")).strip().lower() - self._group_allow_from = self._coerce_allow_list(config.extra.get("group_allow_from") or config.extra.get("groupAllowFrom")) + self._group_allow_from = self._coerce_allow_list( + _resolve_allow_list_source( + config.extra, + "group_allow_from", + "groupAllowFrom", + env_vars=("WHATSAPP_GROUP_ALLOWED_USERS", "WHATSAPP_GROUP_ALLOW_FROM"), + ) + ) self._mention_patterns = self._compile_mention_patterns() self._message_queue: asyncio.Queue = asyncio.Queue() self._bridge_log_fh = None diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 4b5733d16f84..1b675e8035b5 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -33,6 +33,8 @@ import qrcode from 'qrcode-terminal'; import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js'; import { createOutboundIdTracker } from './outbound_ids.js'; import { classifyOwnerMessageGate } from './owner_message_gate.js'; +import { classifyFromMeGroupGate } from './from_me_group_gate.js'; +import { shouldEnforceDmSenderAllowlist } from './dm_allowlist_scope.js'; import { buildPollPayload, buildLocationPayload, @@ -546,17 +548,46 @@ async function startSocket() { // Handle fromMe messages based on mode let fromOwner = false; if (msg.key.fromMe) { - if (isGroup || chatId.includes('status')) { + const isStatus = chatId.includes('status'); + const groupDecision = classifyFromMeGroupGate({ + isGroup, + fromMe: true, + isStatus, + mode: WHATSAPP_MODE, + forwardOwnerMessages: FORWARD_OWNER_MESSAGES, + recentlySent: recentlySentIds.has(msg.key.id), + }); + if (groupDecision.action === 'drop_status') { emitDebugEvent({ stage: 'ignored', - reason: isGroup ? 'from_me_group' : 'from_me_status', + reason: 'from_me_status', chatId: redactWhatsAppId(chatId), }); continue; } - - if (WHATSAPP_MODE === 'bot') { - // Bot mode: separate bot number. fromMe inbound is either + if (groupDecision.action === 'drop_echo') { + continue; + } + if (groupDecision.action === 'drop_from_me_group') { + emitDebugEvent({ + stage: 'ignored', + reason: 'from_me_group', + chatId: redactWhatsAppId(chatId), + }); + try { + console.log(JSON.stringify({ + event: 'ignored', + reason: 'from_me_group', + chatId, + senderId, + })); + } catch {} + continue; + } + if (groupDecision.action === 'forward_owner') { + fromOwner = true; + } else if (WHATSAPP_MODE === 'bot') { + // Bot mode DM: fromMe inbound is either // (a) an echo of our own /send (recentlySentIds will catch it), or // (b) a message the owner typed from their own phone using the // linked-device session. @@ -634,7 +665,18 @@ async function startSocket() { } catch {} continue; } - if (WHATSAPP_DM_POLICY !== 'pairing' && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { + // DM allowlist applies to direct chats only. Group chats are gated by + // WHATSAPP_GROUP_POLICY / WHATSAPP_GROUP_ALLOWED_USERS in the Python + // adapter — applying the DM sender allowlist here blocked every + // non-owner participant (and made allowlisted groups look "dead"). + if ( + shouldEnforceDmSenderAllowlist({ + isGroup, + fromMe: false, + dmPolicy: WHATSAPP_DM_POLICY, + }) + && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR) + ) { try { console.log(JSON.stringify({ event: 'ignored', diff --git a/scripts/whatsapp-bridge/dm_allowlist_scope.js b/scripts/whatsapp-bridge/dm_allowlist_scope.js new file mode 100644 index 000000000000..9aca3c7b727c --- /dev/null +++ b/scripts/whatsapp-bridge/dm_allowlist_scope.js @@ -0,0 +1,24 @@ +/** + * Decide whether the DM sender allowlist should run for this inbound. + * + * Group chats are gated by WHATSAPP_GROUP_POLICY / WHATSAPP_GROUP_ALLOWED_USERS + * on the Python adapter. Applying WHATSAPP_ALLOWED_USERS (DM sender allowlist) + * to group participants blocked every non-owner member and made allowlisted + * support groups look "dead". + * + * @param {object} opts + * @param {boolean} opts.isGroup + * @param {boolean} opts.fromMe + * @param {string} opts.dmPolicy WHATSAPP_DM_POLICY + * @returns {boolean} true if the bridge should enforce the DM sender allowlist + */ +export function shouldEnforceDmSenderAllowlist({ + isGroup = false, + fromMe = false, + dmPolicy = 'allowlist', +} = {}) { + if (fromMe) return false; + if (isGroup) return false; + if (dmPolicy === 'pairing') return false; + return true; +} diff --git a/scripts/whatsapp-bridge/dm_allowlist_scope.test.mjs b/scripts/whatsapp-bridge/dm_allowlist_scope.test.mjs new file mode 100644 index 000000000000..af2dd6cbbddd --- /dev/null +++ b/scripts/whatsapp-bridge/dm_allowlist_scope.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { shouldEnforceDmSenderAllowlist } from './dm_allowlist_scope.js'; + +test('DM stranger under allowlist policy is enforced', () => { + assert.equal( + shouldEnforceDmSenderAllowlist({ + isGroup: false, + fromMe: false, + dmPolicy: 'allowlist', + }), + true, + ); +}); + +test('group messages never run the DM sender allowlist', () => { + assert.equal( + shouldEnforceDmSenderAllowlist({ + isGroup: true, + fromMe: false, + dmPolicy: 'allowlist', + }), + false, + ); +}); + +test('pairing policy skips bridge allowlist (Python pairing handles it)', () => { + assert.equal( + shouldEnforceDmSenderAllowlist({ + isGroup: false, + fromMe: false, + dmPolicy: 'pairing', + }), + false, + ); +}); + +test('fromMe never runs the stranger DM allowlist', () => { + assert.equal( + shouldEnforceDmSenderAllowlist({ + isGroup: false, + fromMe: true, + dmPolicy: 'allowlist', + }), + false, + ); +}); diff --git a/scripts/whatsapp-bridge/from_me_group_gate.js b/scripts/whatsapp-bridge/from_me_group_gate.js new file mode 100644 index 000000000000..d5cfa28c7ffd --- /dev/null +++ b/scripts/whatsapp-bridge/from_me_group_gate.js @@ -0,0 +1,55 @@ +/** + * Pure classifier for fromMe messages that land in a WhatsApp group. + * + * Stock Hermes dropped every fromMe group message (`from_me_group`), which + * blocked two legitimate paths: + * 1. self-chat mode operators talking to the agent in a group + * 2. bot-mode personal-number deployments where the linked phone is the + * owner's own number and they type in an allowlisted group + * + * Group membership / group_policy is enforced on the Python side + * (WHATSAPP_GROUP_ALLOWED_USERS). This gate only decides whether the + * bridge should forward a fromMe group message at all. + * + * @param {object} opts + * @param {boolean} opts.isGroup + * @param {boolean} opts.fromMe + * @param {boolean} opts.isStatus + * @param {string} opts.mode 'bot' | 'self-chat' + * @param {boolean} opts.forwardOwnerMessages WHATSAPP_FORWARD_OWNER_MESSAGES + * @param {boolean} opts.recentlySent true if this message id was our /send + * @returns {{ action: 'not_applicable'|'drop_status'|'drop_echo'|'drop_from_me_group'|'forward_owner' }} + */ +export function classifyFromMeGroupGate({ + isGroup = false, + fromMe = false, + isStatus = false, + mode = 'bot', + forwardOwnerMessages = false, + recentlySent = false, +} = {}) { + if (!fromMe) { + return { action: 'not_applicable' }; + } + if (isStatus) { + return { action: 'drop_status' }; + } + if (!isGroup) { + return { action: 'not_applicable' }; + } + if (recentlySent) { + return { action: 'drop_echo' }; + } + // Self-chat: the linked account is the only speaker the agent ever sees. + // Group fromMe must reach Python so group_policy / group_allow_from apply. + if (mode === 'self-chat') { + return { action: 'forward_owner' }; + } + // Bot mode: only when the operator opts into owner-typed forwards + // (personal-number bot testing). Default stays drop_from_me_group so + // dedicated bot-number deployments are unchanged. + if (forwardOwnerMessages) { + return { action: 'forward_owner' }; + } + return { action: 'drop_from_me_group' }; +} diff --git a/scripts/whatsapp-bridge/from_me_group_gate.test.mjs b/scripts/whatsapp-bridge/from_me_group_gate.test.mjs new file mode 100644 index 000000000000..35a611790363 --- /dev/null +++ b/scripts/whatsapp-bridge/from_me_group_gate.test.mjs @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { classifyFromMeGroupGate } from './from_me_group_gate.js'; + +test('non-fromMe is not applicable', () => { + assert.equal( + classifyFromMeGroupGate({ isGroup: true, fromMe: false }).action, + 'not_applicable', + ); +}); + +test('status broadcasts always drop', () => { + assert.equal( + classifyFromMeGroupGate({ + isGroup: false, + fromMe: true, + isStatus: true, + }).action, + 'drop_status', + ); +}); + +test('fromMe DM is not applicable (handled by owner_message_gate)', () => { + assert.equal( + classifyFromMeGroupGate({ + isGroup: false, + fromMe: true, + mode: 'bot', + forwardOwnerMessages: true, + }).action, + 'not_applicable', + ); +}); + +test('bot mode drops group fromMe by default', () => { + assert.equal( + classifyFromMeGroupGate({ + isGroup: true, + fromMe: true, + mode: 'bot', + forwardOwnerMessages: false, + }).action, + 'drop_from_me_group', + ); +}); + +test('bot mode forwards group fromMe when FORWARD_OWNER_MESSAGES is on', () => { + assert.equal( + classifyFromMeGroupGate({ + isGroup: true, + fromMe: true, + mode: 'bot', + forwardOwnerMessages: true, + }).action, + 'forward_owner', + ); +}); + +test('self-chat mode always forwards group fromMe', () => { + assert.equal( + classifyFromMeGroupGate({ + isGroup: true, + fromMe: true, + mode: 'self-chat', + forwardOwnerMessages: false, + }).action, + 'forward_owner', + ); +}); + +test('echo of our own /send is dropped even when forward is enabled', () => { + assert.equal( + classifyFromMeGroupGate({ + isGroup: true, + fromMe: true, + mode: 'bot', + forwardOwnerMessages: true, + recentlySent: true, + }).action, + 'drop_echo', + ); +}); diff --git a/tests/gateway/test_whatsapp_baileys_allowlist_env.py b/tests/gateway/test_whatsapp_baileys_allowlist_env.py new file mode 100644 index 000000000000..b88ec8395d1d --- /dev/null +++ b/tests/gateway/test_whatsapp_baileys_allowlist_env.py @@ -0,0 +1,121 @@ +"""Regression: Baileys WhatsApp adapter must honor documented env allowlists. + +Wizard / .env-only installs write WHATSAPP_ALLOWED_USERS and +WHATSAPP_GROUP_ALLOWED_USERS but leave PlatformConfig.extra empty. Before +this fix the adapter only read allow_from / group_allow_from from extra, +so dm_policy=allowlist + a non-empty env allowlist still ran with an empty +set and silently dropped every inbound after the bridge queued it. + +Mirrors the WhatsApp Cloud salvage (PR #58504 / #58448) and adopts +key-presence semantics so explicit allow_from: [] stays deny-all even when +a stale env allowlist is present (review feedback on #61924). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from gateway.config import PlatformConfig + + +def _build_adapter(monkeypatch, tmp_path, extra=None, env=None): + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter + + monkeypatch.setattr(WhatsAppAdapter, "_DEFAULT_BRIDGE_DIR", tmp_path / "bridge") + + for key in ( + "WHATSAPP_ALLOWED_USERS", + "WHATSAPP_ALLOW_FROM", + "WHATSAPP_ALLOW_ALL_USERS", + "WHATSAPP_GROUP_ALLOWED_USERS", + "WHATSAPP_GROUP_ALLOW_FROM", + "WHATSAPP_DM_POLICY", + "WHATSAPP_GROUP_POLICY", + ): + monkeypatch.delenv(key, raising=False) + for key, value in (env or {}).items(): + monkeypatch.setenv(key, value) + + config = PlatformConfig(enabled=True, extra=extra or {}) + return WhatsAppAdapter(config) + + +def test_dm_allowlist_falls_back_to_env_var(monkeypatch, tmp_path): + adapter = _build_adapter( + monkeypatch, + tmp_path, + extra={"dm_policy": "allowlist"}, + env={"WHATSAPP_ALLOWED_USERS": "15551234567,15557654321"}, + ) + assert adapter._allow_from == {"15551234567", "15557654321"} + + +def test_group_allowlist_falls_back_to_env_var(monkeypatch, tmp_path): + adapter = _build_adapter( + monkeypatch, + tmp_path, + extra={"group_policy": "allowlist"}, + env={"WHATSAPP_GROUP_ALLOWED_USERS": "120363001234567890@g.us"}, + ) + assert adapter._group_allow_from == {"120363001234567890@g.us"} + + +def test_group_allow_from_legacy_env_alias(monkeypatch, tmp_path): + adapter = _build_adapter( + monkeypatch, + tmp_path, + env={"WHATSAPP_GROUP_ALLOW_FROM": "120363009999999999@g.us"}, + ) + assert "120363009999999999@g.us" in adapter._group_allow_from + + +def test_explicit_empty_allow_from_does_not_fallback_to_env(monkeypatch, tmp_path): + adapter = _build_adapter( + monkeypatch, + tmp_path, + extra={"dm_policy": "allowlist", "allow_from": []}, + env={"WHATSAPP_ALLOWED_USERS": "15551234567"}, + ) + # Explicit empty config must stay deny-all (no silent widen via env). + assert adapter._allow_from == set() + + +def test_explicit_empty_group_allow_from_does_not_fallback_to_env(monkeypatch, tmp_path): + adapter = _build_adapter( + monkeypatch, + tmp_path, + extra={"group_policy": "allowlist", "group_allow_from": []}, + env={"WHATSAPP_GROUP_ALLOWED_USERS": "120363001234567890@g.us"}, + ) + assert adapter._group_allow_from == set() + + +def test_config_extra_wins_over_env(monkeypatch, tmp_path): + adapter = _build_adapter( + monkeypatch, + tmp_path, + extra={"allow_from": ["15550000001"]}, + env={"WHATSAPP_ALLOWED_USERS": "15559999999"}, + ) + assert adapter._allow_from == {"15550000001"} + + +def test_allow_all_users_opts_in_when_no_allowlist_configured(monkeypatch, tmp_path): + adapter = _build_adapter( + monkeypatch, + tmp_path, + env={"WHATSAPP_ALLOW_ALL_USERS": "true"}, + ) + assert adapter._allow_from == {"*"} + + +def test_allow_all_users_does_not_override_explicit_empty(monkeypatch, tmp_path): + adapter = _build_adapter( + monkeypatch, + tmp_path, + extra={"allow_from": []}, + env={"WHATSAPP_ALLOW_ALL_USERS": "true"}, + ) + assert adapter._allow_from == set() diff --git a/tests/gateway/test_whatsapp_group_chat_authz.py b/tests/gateway/test_whatsapp_group_chat_authz.py new file mode 100644 index 000000000000..b20560c5d183 --- /dev/null +++ b/tests/gateway/test_whatsapp_group_chat_authz.py @@ -0,0 +1,145 @@ +"""Gateway authz must treat WHATSAPP_GROUP_ALLOWED_USERS as chat-scoped. + +WHATSAPP_GROUP_ALLOWED_USERS holds group JIDs (same shape as +TELEGRAM_GROUP_ALLOWED_CHATS), not sender user IDs. Without wiring it into +GatewayAuthorizationMixin's chat-allowlist maps, a message that already +passed the adapter's group_policy still hits Unauthorized user for any +sender who is not also on WHATSAPP_ALLOWED_USERS — so customer-support +groups only work for the owner DM allowlist. + +This is the multi-member / support-group path: any participant in an +allowlisted @g.us chat is authorized; DMs stay on the DM allowlist. +""" + +from __future__ import annotations + +import os + +import pytest + +from gateway.authz_mixin import GatewayAuthorizationMixin +from gateway.config import Platform +from gateway.session import SessionSource + + +class _Authz(GatewayAuthorizationMixin): + def __init__(self): + self.adapters = {} + self.config = None + self.pairing_store = None + self.pairing_stores = {} + + +def _clear_allow_env(monkeypatch): + for key in list(os.environ): + if any( + tok in key + for tok in ( + "ALLOW", + "WHATSAPP", + "GATEWAY", + "TELEGRAM", + "DISCORD", + "SIGNAL", + "SLACK", + ) + ): + monkeypatch.delenv(key, raising=False) + + +def _src(**kwargs) -> SessionSource: + return SessionSource(platform=Platform.WHATSAPP, **kwargs) + + +def test_allowlisted_group_authorizes_any_member(monkeypatch): + _clear_allow_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550000001") + monkeypatch.setenv("WHATSAPP_GROUP_ALLOWED_USERS", "120363001234567890@g.us") + authz = _Authz() + + assert authz._is_user_authorized( + _src( + chat_id="120363001234567890@g.us", + chat_type="group", + user_id="999888777@lid", + user_name="Customer", + ) + ) + assert authz._is_user_authorized( + _src( + chat_id="120363001234567890@g.us", + chat_type="group", + user_id="15550000001", + user_name="Owner", + ) + ) + + +def test_non_allowlisted_group_still_denies_strangers(monkeypatch): + _clear_allow_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550000001") + monkeypatch.setenv("WHATSAPP_GROUP_ALLOWED_USERS", "120363001234567890@g.us") + authz = _Authz() + + assert not authz._is_user_authorized( + _src( + chat_id="120363009999999999@g.us", + chat_type="group", + user_id="999888777@lid", + user_name="Customer", + ) + ) + + +def test_dm_stranger_still_denied(monkeypatch): + _clear_allow_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550000001") + monkeypatch.setenv("WHATSAPP_GROUP_ALLOWED_USERS", "120363001234567890@g.us") + authz = _Authz() + + assert not authz._is_user_authorized( + _src( + chat_id="999888777@s.whatsapp.net", + chat_type="dm", + user_id="999888777", + user_name="Stranger", + ) + ) + assert authz._is_user_authorized( + _src( + chat_id="15550000001@s.whatsapp.net", + chat_type="dm", + user_id="15550000001", + user_name="Owner", + ) + ) + + +def test_bare_group_id_in_env_matches_full_jid(monkeypatch): + _clear_allow_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_GROUP_ALLOWED_USERS", "120363001234567890") + authz = _Authz() + + assert authz._is_user_authorized( + _src( + chat_id="120363001234567890@g.us", + chat_type="group", + user_id="anyone@lid", + user_name="Customer", + ) + ) + + +def test_wildcard_group_allowlist(monkeypatch): + _clear_allow_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_GROUP_ALLOWED_USERS", "*") + authz = _Authz() + + assert authz._is_user_authorized( + _src( + chat_id="120363001234567890@g.us", + chat_type="group", + user_id="anyone@lid", + user_name="Customer", + ) + )