emma-hermes/tests/hermes_cli/test_web_verification_admin.py

472 lines
18 KiB
Python

from __future__ import annotations
from dataclasses import replace
from types import SimpleNamespace
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.pairing import PairingStore
from gateway.session import SessionSource
class TestWebVerificationAdmin:
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from gateway.user_verification import GatewayUserStore
self.store = GatewayUserStore()
app.state.gateway_user_store = self.store
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_force_bind_and_list_identities(self):
bind = self.client.post(
"/api/admin/verification/force-bind",
json={
"platform": "telegram",
"external_user_id": "tg-web-1",
"email": "staff4@bremen.com.tw",
"user_name": "Web Tester",
},
)
assert bind.status_code == 200
identity = bind.json()["identity"]
assert identity["state"] == "verified"
assert identity["verified_email"] == "staff4@bremen.com.tw"
listed = self.client.get("/api/admin/verification/identities")
assert listed.status_code == 200
items = listed.json()["items"]
assert any(
item["platform"] == "telegram"
and item["external_user_id"] == "tg-web-1"
and item["verified_email"] == "staff4@bremen.com.tw"
for item in items
)
unbind = self.client.post(
"/api/admin/verification/unbind",
json={"platform": "telegram", "external_user_id": "tg-web-1"},
)
assert unbind.status_code == 200
assert unbind.json()["identity"]["state"] == "new"
assert unbind.json()["identity"]["verified_email"] is None
def test_unbind_revokes_pairing_approval(self):
store = PairingStore()
store._approve_user("telegram", "tg-web-unbind", "Web Tester")
assert store.is_approved("telegram", "tg-web-unbind") is True
bind = self.client.post(
"/api/admin/verification/force-bind",
json={
"platform": "telegram",
"external_user_id": "tg-web-unbind",
"email": "staff-unbind@bremen.com.tw",
"user_name": "Web Tester",
},
)
assert bind.status_code == 200
unbind = self.client.post(
"/api/admin/verification/unbind",
json={"platform": "telegram", "external_user_id": "tg-web-unbind"},
)
assert unbind.status_code == 200
assert unbind.json()["identity"]["state"] == "new"
assert store.is_approved("telegram", "tg-web-unbind") is False
audit = self.client.get("/api/admin/verification/audit", params={"limit": 20})
assert audit.status_code == 200
assert any(
item["action"] == "unbind_identity"
and item["platform"] == "telegram"
and item["external_user_id"] == "tg-web-unbind"
and item["details"].get("pairing_revoked") is True
for item in audit.json()["items"]
)
def test_unbind_removes_runner_authorization_when_pairing_was_only_grant(self, monkeypatch):
from gateway.run import GatewayRunner
for key in (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
"TELEGRAM_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)
external_user_id = "tg-web-authz"
PairingStore()._approve_user("telegram", external_user_id, "Web Tester")
bind = self.client.post(
"/api/admin/verification/force-bind",
json={
"platform": "telegram",
"external_user_id": external_user_id,
"email": "staff-authz@bremen.com.tw",
"user_name": "Web Tester",
},
)
assert bind.status_code == 200
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True)})
runner.adapters = {Platform.TELEGRAM: SimpleNamespace()}
runner.pairing_store = PairingStore()
source = SessionSource(
platform=Platform.TELEGRAM,
user_id=external_user_id,
chat_id=external_user_id,
user_name="Web Tester",
chat_type="dm",
)
assert runner._is_user_authorized(source) is True
unbind = self.client.post(
"/api/admin/verification/unbind",
json={"platform": "telegram", "external_user_id": external_user_id},
)
assert unbind.status_code == 200
assert runner._is_user_authorized(source) is False
@pytest.mark.parametrize(
("platform_enum", "platform_name", "external_user_id", "email"),
[
("TELEGRAM", "telegram", "tg-web-resend", "staff-resend@journeys.com.tw"),
("line", "line", "line-web-resend", "line-resend@bremen.com.tw"),
],
)
def test_resend_endpoint_sends_verification_email(
self,
monkeypatch,
platform_enum,
platform_name,
external_user_id,
email,
):
from gateway.config import Platform
from gateway.session import SessionSource
from gateway import user_verification as uv
from hermes_cli import web_server
source = SessionSource(
platform=(getattr(Platform, platform_enum) if platform_enum.isupper() else Platform(platform_enum)),
chat_id=external_user_id,
user_id=external_user_id,
user_name="Resend Tester",
chat_type="dm",
)
decision = self.store.process_inbound_message(
source,
email,
public_base_url="https://agent.example.com",
)
assert decision.action == "send_verification_email"
with self.store._connect() as conn:
conn.execute(
"UPDATE verification_requests SET last_sent_at = datetime('now', '-2 minutes') WHERE platform = ? AND external_user_id = ?",
(platform_name, external_user_id),
)
conn.commit()
calls: list[tuple[str, str, str]] = []
async def fake_send(to_email: str, *, token: str, verification_url: str, platform: str, external_user_id: str, pconfig=None):
calls.append((to_email, token, verification_url))
return {"success": True, "platform": platform, "chat_id": to_email}
monkeypatch.setattr(uv, "send_verification_email", fake_send)
monkeypatch.setattr(
web_server,
"_gateway_platform_config",
lambda platform_id: (None, None, SimpleNamespace(extra={})),
)
resp = self.client.post(
"/api/admin/verification/resend",
json={"platform": platform_name, "external_user_id": external_user_id},
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["result"]["email"] == email
assert body["result"]["token"]
assert calls == [
(
email,
body["result"]["token"],
body["result"]["verification_url"],
)
]
def test_public_verify_endpoint_accepts_token_without_session_header(self):
from gateway.config import Platform
from gateway.session import SessionSource
from hermes_cli import web_server
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="tg-web-2",
user_id="tg-web-2",
user_name="Public Verify",
chat_type="dm",
)
decision = self.store.process_inbound_message(
source,
"staff5@journeys.com.tw",
public_base_url="https://agent.example.com",
)
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
try:
public_client = self.client.__class__(self.client.app, base_url="https://fly-app.fly.dev")
resp = public_client.get(f"/api/verification/verify?token={decision.token}")
finally:
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
assert resp.status_code == 200
assert "驗證成功" in resp.text
@pytest.mark.parametrize(
("platform_obj", "platform_name", "external_user_id", "email"),
[
(Platform.TELEGRAM, "telegram", "tg-state-machine", "tg-state@journeys.com.tw"),
(Platform("line"), "line", "UstateMachine", "line-state@bremen.com.tw"),
],
)
def test_verification_state_machine_bind_handoff_unbind(self, monkeypatch, platform_obj, platform_name, external_user_id, email):
from gateway.run import GatewayRunner
from gateway.platform_registry import platform_registry, PlatformEntry
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
for key in (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
"TELEGRAM_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
"LINE_ALLOWED_USERS",
"LINE_ALLOWED_GROUPS",
"LINE_ALLOWED_ROOMS",
"LINE_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)
if platform_name == "line":
try:
platform_registry.register(
PlatformEntry(
name="line",
label="LINE",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
allowed_users_env="LINE_ALLOWED_USERS",
allow_all_env="LINE_ALLOW_ALL_USERS",
)
)
except Exception:
pass
source = SessionSource(
platform=platform_obj,
chat_id=external_user_id,
user_id=external_user_id,
user_name="State Machine Tester",
chat_type="dm",
)
decision = self.store.process_inbound_message(
source,
email,
public_base_url="https://agent.example.com",
)
assert decision.action == "send_verification_email"
assert decision.token
assert self.store.is_verified_source(source) is False
assert self.store.get_verified_email_for_source(source) is None
verify = self.store.verify_token(decision.token)
assert verify.success is True
assert self.store.is_verified_source(source) is True
assert self.store.get_verified_email_for_source(source) == email
assert self.store.bound_email_matches_source(source, email) is True
assert self.store.bound_email_matches_source(source, f"wrong+{email}") is False
if platform_name == "telegram":
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True)})
runner.adapters = {Platform.TELEGRAM: SimpleNamespace()}
runner.pairing_store = PairingStore()
assert runner._is_user_authorized(source) is False
PairingStore()._approve_user(platform_name, external_user_id, "State Machine Tester")
assert runner._is_user_authorized(source) is True
else:
line_mod = load_plugin_adapter("line")
sent = {}
async def _fake_email_send(_cfg, chat_id, message, **kwargs):
sent["chat_id"] = chat_id
sent["message"] = message
sent["kwargs"] = kwargs
return {"success": True}
monkeypatch.setattr(line_mod, "_email_standalone_send", _fake_email_send)
adapter = line_mod.LineAdapter(
PlatformConfig(
enabled=True,
extra={"channel_access_token": "tok", "channel_secret": "sec"},
)
)
adapter._client = SimpleNamespace(push=None, reply=None)
payload = pytest.importorskip("asyncio").run(adapter._send_email_quota_fallback(external_user_id, "hello", mode="final"))
assert payload is not None
assert payload["email"] == email
assert sent["chat_id"] == email
unbind = self.client.post(
"/api/admin/verification/unbind",
json={"platform": platform_name, "external_user_id": external_user_id},
)
assert unbind.status_code == 200
assert unbind.json()["identity"]["state"] == "new"
assert self.store.is_verified_source(source) is False
assert self.store.get_verified_email_for_source(source) is None
assert self.store.bound_email_matches_source(source, email) is False
if platform_name == "telegram":
assert runner._is_user_authorized(source) is False
else:
payload_after = pytest.importorskip("asyncio").run(adapter._send_email_quota_fallback(external_user_id, "hello", mode="final"))
assert payload_after is None
def test_loopback_role_endpoint_defaults_to_admin(self):
resp = self.client.get("/api/admin/verification/role")
assert resp.status_code == 200
assert resp.json()["role"] == "admin"
def test_dashboard_role_assignment_round_trip(self):
set_role = self.client.post(
"/api/admin/verification/roles",
json={"email": "viewer@example.com", "role": "viewer"},
)
assert set_role.status_code == 200
assert set_role.json()["assignment"]["role"] == "viewer"
listed = self.client.get("/api/admin/verification/roles")
assert listed.status_code == 200
assert any(item["email"] == "viewer@example.com" and item["role"] == "viewer" for item in listed.json()["items"])
def test_quota_and_model_policy_round_trip(self):
bind = self.client.post(
"/api/admin/verification/force-bind",
json={
"platform": "telegram",
"external_user_id": "tg-web-3",
"email": "staff6@bremen.com.tw",
},
)
principal_id = bind.json()["identity"]["principal_id"]
quota = self.client.post(
"/api/admin/quota-policies",
json={
"principal_id": principal_id,
"daily_message_limit": 30,
"daily_token_limit": 50000,
"notes": "vip",
},
)
assert quota.status_code == 200
assert quota.json()["policy"]["daily_message_limit"] == 30
model = self.client.post(
"/api/admin/model-policies",
json={
"principal_id": principal_id,
"allowed_models": ["openai/gpt-5", "anthropic/claude-sonnet-4"],
"default_model": "openai/gpt-5",
},
)
assert model.status_code == 200
assert model.json()["policy"]["allowed_models"] == [
"openai/gpt-5",
"anthropic/claude-sonnet-4",
]
self.store.increment_principal_usage(
principal_id,
input_tokens=210,
output_tokens=90,
message_count=3,
model="openai/gpt-5",
)
quota_list = self.client.get("/api/admin/quota-policies")
model_list = self.client.get("/api/admin/model-policies")
usage_list = self.client.get("/api/admin/principal-usage")
assert quota_list.status_code == 200
assert model_list.status_code == 200
assert usage_list.status_code == 200
assert any(item["principal_id"] == principal_id for item in quota_list.json()["items"])
assert any(item["principal_id"] == principal_id for item in model_list.json()["items"])
usage_item = next(item for item in usage_list.json()["items"] if item["principal_id"] == principal_id)
assert usage_item["email"] == "staff6@bremen.com.tw"
assert usage_item["message_count"] == 3
assert usage_item["total_tokens"] == 300
assert usage_item["daily_token_limit"] == 50000
def test_principals_and_audit_endpoints_round_trip(self):
bind = self.client.post(
"/api/admin/verification/force-bind",
json={
"platform": "line",
"external_user_id": "line-web-principal",
"email": "staff7@journeys.com.tw",
"user_name": "Principal Tester",
},
)
assert bind.status_code == 200
principal_id = bind.json()["identity"]["principal_id"]
principals = self.client.get("/api/admin/verification/principals")
assert principals.status_code == 200
principal = next(item for item in principals.json()["items"] if item["principal_id"] == principal_id)
assert principal["email"] == "staff7@journeys.com.tw"
assert any(
identity["platform"] == "line"
and identity["external_user_id"] == "line-web-principal"
for identity in principal["identities"]
)
audit = self.client.get("/api/admin/verification/audit", params={"limit": 20})
assert audit.status_code == 200
assert any(
item["action"] == "force_bind_identity"
and item["platform"] == "line"
and item["external_user_id"] == "line-web-principal"
for item in audit.json()["items"]
)