mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
212 lines
8 KiB
Python
212 lines
8 KiB
Python
"""Tests for the WhatsApp Cloud API setup wizard.
|
|
|
|
Covers:
|
|
- Field-shape validators (catch the #1 setup mistake — phone number in
|
|
the Phone Number ID field — plus the OpenAI / Slack / GitHub token
|
|
paste-by-mistake cases)
|
|
- Wizard end-to-end flow with mocked stdin/stdout — verifies each step
|
|
writes the expected env var, validation errors block invalid input,
|
|
optional fields can be skipped, and the SETUP COMPLETE block prints
|
|
the post-setup tunnel + Meta-dashboard instructions the user needs
|
|
(the wizard can't smoke-test reachability itself because the gateway
|
|
isn't running yet during setup).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.setup_whatsapp_cloud import (
|
|
_validate_phone_number_id,
|
|
_validate_waba_id,
|
|
_validate_app_id,
|
|
_validate_app_secret,
|
|
_validate_access_token,
|
|
run_whatsapp_cloud_setup,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validator tests — the cheap, exhaustive coverage layer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPhoneNumberIdValidator:
|
|
def test_accepts_real_meta_phone_number_id(self):
|
|
ok, _ = _validate_phone_number_id("7794189252778687")
|
|
assert ok
|
|
|
|
def test_rejects_actual_phone_number_with_helpful_message(self):
|
|
"""The #1 setup trap — pasting the phone number instead of the ID."""
|
|
ok, reason = _validate_phone_number_id("15556422442")
|
|
assert not ok
|
|
assert "phone number" in reason.lower()
|
|
assert "Phone number ID" in reason # tells them where to look
|
|
|
|
|
|
class TestAccessTokenValidator:
|
|
|
|
def test_rejects_empty(self):
|
|
ok, reason = _validate_access_token("")
|
|
assert not ok
|
|
assert "required" in reason.lower()
|
|
|
|
|
|
def test_rejects_github_token_with_helpful_message(self):
|
|
ok, reason = _validate_access_token("ghp_abcdefghijklmnop")
|
|
assert not ok
|
|
assert "GitHub" in reason
|
|
|
|
|
|
class TestAppSecretValidator:
|
|
|
|
|
|
def test_rejects_wrong_length(self):
|
|
ok, reason = _validate_app_secret("0123456789abcdef") # 16 chars
|
|
assert not ok
|
|
assert "32" in reason
|
|
|
|
def test_rejects_non_hex(self):
|
|
ok, reason = _validate_app_secret("zzzz56789abcdef0123456789abcdezz")
|
|
assert not ok
|
|
assert "hex" in reason.lower()
|
|
|
|
|
|
|
|
class TestWabaIdValidator:
|
|
|
|
def test_rejects_non_numeric(self):
|
|
ok, _ = _validate_waba_id("abc-def")
|
|
assert not ok
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end wizard flow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_home(tmp_path, monkeypatch):
|
|
"""Redirect HERMES_HOME so save_env_value writes into a temp .env."""
|
|
home = tmp_path / "home"
|
|
hermes = home / ".hermes"
|
|
hermes.mkdir(parents=True)
|
|
monkeypatch.setattr(Path, "home", lambda: home)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes))
|
|
for key in list(os.environ):
|
|
if key.startswith("WHATSAPP_CLOUD_"):
|
|
monkeypatch.delenv(key, raising=False)
|
|
return hermes
|
|
|
|
|
|
def _env_value(hermes_home: Path, key: str) -> str | None:
|
|
env_file = hermes_home / ".env"
|
|
if not env_file.exists():
|
|
return None
|
|
for line in env_file.read_text().splitlines():
|
|
if "=" not in line:
|
|
continue
|
|
k, _, v = line.partition("=")
|
|
if k.strip() == key:
|
|
return v.strip().strip('"').strip("'")
|
|
return None
|
|
|
|
|
|
class TestWizardFlow:
|
|
def test_happy_path_minimal(self, isolated_home, monkeypatch):
|
|
"""Provide only the required fields; skip optional steps."""
|
|
inputs = iter([
|
|
"", # press Enter to continue
|
|
"7794189252778687", # Phone Number ID
|
|
"EAA" + "x" * 200, # Access Token
|
|
"0123456789abcdef0123456789abcdef", # App Secret
|
|
"", # App ID — skip
|
|
"", # WABA ID — skip
|
|
"15551234567", # Allowed users
|
|
])
|
|
monkeypatch.setattr("builtins.input", lambda *a, **kw: next(inputs))
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
rc = run_whatsapp_cloud_setup()
|
|
assert rc == 0
|
|
out = buf.getvalue()
|
|
assert "SETUP COMPLETE" in out
|
|
# Required fields written
|
|
assert _env_value(isolated_home, "WHATSAPP_CLOUD_PHONE_NUMBER_ID") == "7794189252778687"
|
|
assert _env_value(isolated_home, "WHATSAPP_CLOUD_ACCESS_TOKEN").startswith("EAA")
|
|
assert len(_env_value(isolated_home, "WHATSAPP_CLOUD_APP_SECRET")) == 32
|
|
assert _env_value(isolated_home, "WHATSAPP_CLOUD_ALLOWED_USERS") == "15551234567"
|
|
# Verify token auto-generated
|
|
assert _env_value(isolated_home, "WHATSAPP_CLOUD_VERIFY_TOKEN")
|
|
# Optional fields stayed unset
|
|
assert _env_value(isolated_home, "WHATSAPP_CLOUD_APP_ID") is None
|
|
assert _env_value(isolated_home, "WHATSAPP_CLOUD_WABA_ID") is None
|
|
|
|
|
|
def test_verify_token_is_auto_generated(self, isolated_home, monkeypatch):
|
|
"""The verify token is one of the few things the user shouldn't
|
|
have to invent. Wizard generates a strong random one."""
|
|
inputs = iter([
|
|
"", # continue
|
|
"7794189252778687", # Phone ID
|
|
"EAA" + "x" * 200, # Token
|
|
"0123456789abcdef0123456789abcdef", # App Secret
|
|
"", # App ID — skip
|
|
"", # WABA ID — skip
|
|
"15551234567", # Allowed users
|
|
])
|
|
monkeypatch.setattr("builtins.input", lambda *a, **kw: next(inputs))
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
run_whatsapp_cloud_setup()
|
|
verify_token = _env_value(isolated_home, "WHATSAPP_CLOUD_VERIFY_TOKEN")
|
|
assert verify_token is not None
|
|
# secrets.token_urlsafe(32) produces ~43 chars (base64-of-32-bytes)
|
|
assert len(verify_token) >= 32
|
|
# Should also be echoed to user output so they can paste into Meta
|
|
assert verify_token in buf.getvalue()
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
# Profile polish block (SETUP COMPLETE → optional WhatsApp profile setup)
|
|
# =========================================================================
|
|
|
|
|
|
class TestProfilePolishGuidance:
|
|
"""The wizard can't set the bot's WhatsApp display name or profile
|
|
picture via the API — those go through Meta's Business Manager UI.
|
|
Verify that the SETUP COMPLETE block points the user at the right
|
|
place rather than leaving them to figure it out on their own."""
|
|
|
|
|
|
def test_polish_block_deeplinks_when_waba_id_known(
|
|
self, isolated_home, monkeypatch
|
|
):
|
|
"""If the user gave us the WABA ID earlier in the wizard, the
|
|
Business Manager URL should pre-select their account."""
|
|
waba = "987654321098765"
|
|
inputs = iter([
|
|
"",
|
|
"7794189252778687",
|
|
"EAA" + "x" * 200,
|
|
"0123456789abcdef0123456789abcdef",
|
|
"", # App ID — skip
|
|
waba, # WABA ID — provided
|
|
"15551234567",
|
|
])
|
|
monkeypatch.setattr("builtins.input", lambda *a, **kw: next(inputs))
|
|
buf = io.StringIO()
|
|
with redirect_stdout(buf):
|
|
run_whatsapp_cloud_setup()
|
|
out = buf.getvalue()
|
|
# Deep-linked URL with the user's WABA pre-selected
|
|
assert f"waba_id={waba}" in out
|
|
# Without WABA, we tell the user they'll need to pick their account
|
|
assert "select your WhatsApp Business Account" not in out
|