/ tests / gateway / test_setup_feishu.py
test_setup_feishu.py
  1  """Tests for _setup_feishu() in hermes_cli/gateway.py.
  2  
  3  Verifies that the interactive setup writes env vars that correctly drive the
  4  Feishu adapter: credentials, connection mode, DM policy, and group policy.
  5  """
  6  
  7  import os
  8  from unittest.mock import patch
  9  
 10  
 11  # ---------------------------------------------------------------------------
 12  # Helpers
 13  # ---------------------------------------------------------------------------
 14  
 15  def _run_setup_feishu(
 16      *,
 17      qr_result=None,
 18      prompt_yes_no_responses=None,
 19      prompt_choice_responses=None,
 20      prompt_responses=None,
 21      existing_env=None,
 22  ):
 23      """Run _setup_feishu() with mocked I/O and return the env vars that were saved.
 24  
 25      Returns a dict of {env_var_name: value} for all save_env_value calls.
 26      """
 27      existing_env = existing_env or {}
 28      prompt_yes_no_responses = list(prompt_yes_no_responses or [True])
 29      # QR path: method(0), dm(0), group(0) — 3 choices (no connection mode)
 30      # Manual path: method(1), domain(0), connection(0), dm(0), group(0) — 5 choices
 31      prompt_choice_responses = list(prompt_choice_responses or [0, 0, 0])
 32      prompt_responses = list(prompt_responses or [""])
 33  
 34      saved_env = {}
 35  
 36      def mock_save(name, value):
 37          saved_env[name] = value
 38  
 39      def mock_get(name):
 40          return existing_env.get(name, "")
 41  
 42      with patch("hermes_cli.gateway.save_env_value", side_effect=mock_save), \
 43           patch("hermes_cli.gateway.get_env_value", side_effect=mock_get), \
 44           patch("hermes_cli.gateway.prompt_yes_no", side_effect=prompt_yes_no_responses), \
 45           patch("hermes_cli.gateway.prompt_choice", side_effect=prompt_choice_responses), \
 46           patch("hermes_cli.gateway.prompt", side_effect=prompt_responses), \
 47           patch("hermes_cli.gateway.print_info"), \
 48           patch("hermes_cli.gateway.print_success"), \
 49           patch("hermes_cli.gateway.print_warning"), \
 50           patch("hermes_cli.gateway.print_error"), \
 51           patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \
 52           patch("gateway.platforms.feishu.qr_register", return_value=qr_result):
 53  
 54          from hermes_cli.gateway import _setup_feishu
 55          _setup_feishu()
 56  
 57      return saved_env
 58  
 59  
 60  # ---------------------------------------------------------------------------
 61  # QR scan-to-create path
 62  # ---------------------------------------------------------------------------
 63  
 64  class TestSetupFeishuQrPath:
 65      """Tests for the QR scan-to-create happy path."""
 66  
 67      def test_qr_success_saves_core_credentials(self):
 68          env = _run_setup_feishu(
 69              qr_result={
 70                  "app_id": "cli_test",
 71                  "app_secret": "secret_test",
 72                  "domain": "feishu",
 73                  "open_id": "ou_owner",
 74                  "bot_name": "TestBot",
 75                  "bot_open_id": "ou_bot",
 76              },
 77              prompt_yes_no_responses=[True],        # Start QR
 78              prompt_choice_responses=[0, 0, 0],  # method=QR, dm=pairing, group=open
 79              prompt_responses=[""],                  # home channel: skip
 80          )
 81          assert env["FEISHU_APP_ID"] == "cli_test"
 82          assert env["FEISHU_APP_SECRET"] == "secret_test"
 83          assert env["FEISHU_DOMAIN"] == "feishu"
 84  
 85      def test_qr_success_does_not_persist_bot_identity(self):
 86          """Bot identity is discovered at runtime by _hydrate_bot_identity — not persisted
 87          in env, so it stays fresh if the user renames the bot later."""
 88          env = _run_setup_feishu(
 89              qr_result={
 90                  "app_id": "cli_test",
 91                  "app_secret": "secret_test",
 92                  "domain": "feishu",
 93                  "open_id": "ou_owner",
 94                  "bot_name": "TestBot",
 95                  "bot_open_id": "ou_bot",
 96              },
 97              prompt_yes_no_responses=[True],
 98              prompt_choice_responses=[0, 0, 0],
 99              prompt_responses=[""],
100          )
101          assert "FEISHU_BOT_OPEN_ID" not in env
102          assert "FEISHU_BOT_NAME" not in env
103  
104  
105  # ---------------------------------------------------------------------------
106  # Connection mode
107  # ---------------------------------------------------------------------------
108  
109  class TestSetupFeishuConnectionMode:
110      """Connection mode: QR always websocket, manual path lets user choose."""
111  
112      def test_qr_path_defaults_to_websocket(self):
113          env = _run_setup_feishu(
114              qr_result={
115                  "app_id": "cli_test", "app_secret": "s", "domain": "feishu",
116                  "open_id": None, "bot_name": None, "bot_open_id": None,
117              },
118              prompt_choice_responses=[0, 0, 0],  # method=QR, dm=pairing, group=open
119              prompt_responses=[""],
120          )
121          assert env["FEISHU_CONNECTION_MODE"] == "websocket"
122  
123      @patch("gateway.platforms.feishu.probe_bot", return_value=None)
124      def test_manual_path_websocket(self, _mock_probe):
125          env = _run_setup_feishu(
126              qr_result=None,
127              prompt_choice_responses=[1, 0, 0, 0, 0],  # method=manual, domain=feishu, connection=ws, dm=pairing, group=open
128              prompt_responses=["cli_manual", "secret_manual", ""],  # app_id, app_secret, home_channel
129          )
130          assert env["FEISHU_CONNECTION_MODE"] == "websocket"
131  
132      @patch("gateway.platforms.feishu.probe_bot", return_value=None)
133      def test_manual_path_webhook(self, _mock_probe):
134          env = _run_setup_feishu(
135              qr_result=None,
136              prompt_choice_responses=[1, 0, 1, 0, 0],  # method=manual, domain=feishu, connection=webhook, dm=pairing, group=open
137              prompt_responses=["cli_manual", "secret_manual", ""],  # app_id, app_secret, home_channel
138          )
139          assert env["FEISHU_CONNECTION_MODE"] == "webhook"
140  
141  
142  # ---------------------------------------------------------------------------
143  # DM security policy
144  # ---------------------------------------------------------------------------
145  
146  class TestSetupFeishuDmPolicy:
147      """DM policy must use platform-scoped FEISHU_ALLOW_ALL_USERS, not the global flag."""
148  
149      def _run_with_dm_choice(self, dm_choice_idx, prompt_responses=None):
150          return _run_setup_feishu(
151              qr_result={
152                  "app_id": "cli_test", "app_secret": "s", "domain": "feishu",
153                  "open_id": "ou_owner", "bot_name": None, "bot_open_id": None,
154              },
155              prompt_yes_no_responses=[True],
156              prompt_choice_responses=[0, dm_choice_idx, 0],  # method=QR, dm=<choice>, group=open
157              prompt_responses=prompt_responses or [""],
158          )
159  
160      def test_pairing_sets_feishu_allow_all_false(self):
161          env = self._run_with_dm_choice(0)
162          assert env["FEISHU_ALLOW_ALL_USERS"] == "false"
163          assert env["FEISHU_ALLOWED_USERS"] == ""
164          assert "GATEWAY_ALLOW_ALL_USERS" not in env
165  
166      def test_allow_all_sets_feishu_allow_all_true(self):
167          env = self._run_with_dm_choice(1)
168          assert env["FEISHU_ALLOW_ALL_USERS"] == "true"
169          assert env["FEISHU_ALLOWED_USERS"] == ""
170          assert "GATEWAY_ALLOW_ALL_USERS" not in env
171  
172      def test_allowlist_sets_feishu_allow_all_false_with_list(self):
173          env = self._run_with_dm_choice(2, prompt_responses=["ou_user1,ou_user2", ""])
174          assert env["FEISHU_ALLOW_ALL_USERS"] == "false"
175          assert env["FEISHU_ALLOWED_USERS"] == "ou_user1,ou_user2"
176          assert "GATEWAY_ALLOW_ALL_USERS" not in env
177  
178      def test_allowlist_prepopulates_with_scan_owner_open_id(self):
179          """When open_id is available from QR scan, it should be the default allowlist value."""
180          # We return the owner's open_id from prompt (+ empty home channel).
181          env = self._run_with_dm_choice(2, prompt_responses=["ou_owner", ""])
182          assert env["FEISHU_ALLOWED_USERS"] == "ou_owner"
183  
184  
185  
186  # ---------------------------------------------------------------------------
187  # Group policy
188  # ---------------------------------------------------------------------------
189  
190  class TestSetupFeishuGroupPolicy:
191  
192      def test_open_with_mention(self):
193          env = _run_setup_feishu(
194              qr_result={
195                  "app_id": "cli_test", "app_secret": "s", "domain": "feishu",
196                  "open_id": None, "bot_name": None, "bot_open_id": None,
197              },
198              prompt_yes_no_responses=[True],
199              prompt_choice_responses=[0, 0, 0],  # method=QR, dm=pairing, group=open
200              prompt_responses=[""],
201          )
202          assert env["FEISHU_GROUP_POLICY"] == "open"
203  
204      def test_disabled(self):
205          env = _run_setup_feishu(
206              qr_result={
207                  "app_id": "cli_test", "app_secret": "s", "domain": "feishu",
208                  "open_id": None, "bot_name": None, "bot_open_id": None,
209              },
210              prompt_yes_no_responses=[True],
211              prompt_choice_responses=[0, 0, 1],  # method=QR, dm=pairing, group=disabled
212              prompt_responses=[""],
213          )
214          assert env["FEISHU_GROUP_POLICY"] == "disabled"
215  
216  
217  # ---------------------------------------------------------------------------
218  # Adapter integration: env vars → FeishuAdapterSettings
219  # ---------------------------------------------------------------------------
220  
221  class TestSetupFeishuAdapterIntegration:
222      """Verify that env vars written by _setup_feishu() produce a valid adapter config.
223  
224      This bridges the gap between 'setup wrote the right env vars' and
225      'the adapter will actually initialize correctly from those vars'.
226      """
227  
228      def _make_env_from_setup(self, dm_idx=0, group_idx=0):
229          """Run _setup_feishu via QR path and return the env vars it would write."""
230          return _run_setup_feishu(
231              qr_result={
232                  "app_id": "cli_test_app",
233                  "app_secret": "test_secret_value",
234                  "domain": "feishu",
235                  "open_id": "ou_owner",
236                  "bot_name": "IntegrationBot",
237                  "bot_open_id": "ou_bot_integration",
238              },
239              prompt_yes_no_responses=[True],
240              prompt_choice_responses=[0, dm_idx, group_idx],  # method=QR, dm, group
241              prompt_responses=[""],
242          )
243  
244      @patch.dict(os.environ, {}, clear=True)
245      def test_qr_env_produces_valid_adapter_settings(self):
246          """QR setup → adapter initializes with websocket mode."""
247          env = self._make_env_from_setup()
248  
249          with patch.dict(os.environ, env, clear=True):
250              from gateway.config import PlatformConfig
251              from gateway.platforms.feishu import FeishuAdapter
252              adapter = FeishuAdapter(PlatformConfig())
253              assert adapter._app_id == "cli_test_app"
254              assert adapter._app_secret == "test_secret_value"
255              assert adapter._domain_name == "feishu"
256              assert adapter._connection_mode == "websocket"
257  
258      @patch.dict(os.environ, {}, clear=True)
259      def test_open_dm_env_sets_correct_adapter_state(self):
260          """Setup with 'allow all DMs' → adapter sees allow-all flag."""
261          env = self._make_env_from_setup(dm_idx=1)
262  
263          with patch.dict(os.environ, env, clear=True):
264              from gateway.platforms.feishu import FeishuAdapter
265              from gateway.config import PlatformConfig
266              # Verify adapter initializes without error and env var is correct.
267              FeishuAdapter(PlatformConfig())
268              assert os.getenv("FEISHU_ALLOW_ALL_USERS") == "true"
269  
270      @patch.dict(os.environ, {}, clear=True)
271      def test_group_open_env_sets_adapter_group_policy(self):
272          """Setup with 'open groups' → adapter group_policy is 'open'."""
273          env = self._make_env_from_setup(group_idx=0)
274  
275          with patch.dict(os.environ, env, clear=True):
276              from gateway.config import PlatformConfig
277              from gateway.platforms.feishu import FeishuAdapter
278              adapter = FeishuAdapter(PlatformConfig())
279              assert adapter._group_policy == "open"