Files
kefu/deploy/mobile-http-20260928/server-bundle/tests/test_remote_api.py
T
2026-09-29 15:52:07 +08:00

724 lines
38 KiB
Python

# -*- coding: utf-8 -*-
"""手机远程控制台、桌面命令通道与服务端黑名单。
重点是边界:手机令牌和桌面令牌互不通用,没有 ZYT 权限的人拦得住,只能动自己
名下的电脑,命令只会被目标电脑领走,手机登录不会把值班电脑顶下线。
"""
import io
import json
import os
import shutil
import tempfile
import threading
import time
import urllib.error
from pathlib import Path
from unittest import TestCase, main, mock
from fastapi.testclient import TestClient
import admin_api
import admin_backend as ab
import remote_store
import zyt_auth
STRONG = "Zhen@2026Ops"
LAN = {"X-Real-IP": "192.168.1.20"}
CONTROL = remote_store.PERM_REMOTE_CONTROL
ALL = remote_store.PERM_REMOTE_ALL
ADD = remote_store.PERM_BLACKLIST_ADD
REMOVE = remote_store.PERM_BLACKLIST_REMOVE
class FakeZyt:
"""ZYT 的替身:账号密码、终端 9 令牌、/adminapi/desktop/session。"""
def __init__(self):
self.users = {
"kefu1": {"id": 101, "password": "pw-kefu1", "name": "客服一", "root": False,
"permissions": [CONTROL, ADD]},
"kefu2": {"id": 202, "password": "pw-kefu2", "name": "客服二", "root": False,
"permissions": []},
"boss": {"id": 303, "password": "pw-boss", "name": "主管", "root": False,
"permissions": [ALL, ADD, REMOVE]},
}
self.tokens: dict[str, str] = {}
self.login_calls = 0
self.login_delay = 0.0
self.login_error: Exception | None = None
self.identity_error: Exception | None = None
self.logged_out: list[str] = []
self._count_lock = threading.Lock()
def login(self, account, password):
with self._count_lock:
self.login_calls += 1
number = self.login_calls
if self.login_delay:
time.sleep(self.login_delay)
if self.login_error is not None:
raise self.login_error
user = self.users.get(account)
if user is None:
raise zyt_auth.ZytLoginError("账号或密码错误", kind="unknown_account")
if user["password"] != password:
raise zyt_auth.ZytLoginError("账号或密码错误", kind="credentials")
if user.get("is_paw") == 0:
raise zyt_auth.ZytLoginError("该账号仍在使用初始密码", kind="password_change_required")
token = f"zyt-mobile-{account}-{number}"
self.tokens[token] = account
return token
def identity_for(self, account, terminal):
user = self.users[account]
return {
"user_id": f"admin:{user['id']}", "sn": account, "nickname": user["name"],
"mobile": "", "avatar": "", "terminal": terminal, "status": "active",
"root": user["root"], "role_ids": [2], "permissions": list(user["permissions"]),
"permissions_known": True,
}
def identity(self, token):
if self.identity_error is not None:
raise self.identity_error
account = self.tokens.get(token)
if account is None:
raise zyt_auth.ZytLoginError("登录已失效,请重新登录", kind="rejected")
return self.identity_for(account, 9)
def logout(self, token):
self.logged_out.append(token)
self.tokens.pop(token, None)
class _Base(TestCase):
budget = "0"
def setUp(self):
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, True)
self.db = ab.Database(self.root / "t.db")
self.db.initialize("Admin@123456")
uid = self.db.authenticate("admin", "Admin@123456")["id"]
self.db.change_password(uid, "Admin@123456", STRONG, "1.1.1.1")
self.zyt = FakeZyt()
env = mock.patch.dict(os.environ, {
"MOBILE_LOGIN_UPSTREAM_FAILURE_BUDGET": self.budget, "MOBILE_REQUIRE_HTTPS": "auto",
})
env.start()
self.addCleanup(env.stop)
self.client = TestClient(admin_api.create_app(
self.root / "t.db", zyt_token_verifier=self._verify_desktop, zyt_mobile_auth=self.zyt,
))
self.addCleanup(self.client.close)
def _verify_desktop(self, token):
account = token.removeprefix("zyt-desktop-")
if account not in self.zyt.users:
raise zyt_auth.ZytAuthError("登录已失效")
return self.zyt.identity_for(account, 7)
# ── 登录助手 ──
def desktop_login(self, account="kefu1", device_id="dev-0000000000000001"):
response = self.client.post(
"/api/v2/desktop/auth/exchange",
headers={"Authorization": f"Bearer zyt-desktop-{account}"},
json={"device_id": device_id, "device_name": f"PC-{account}", "app_version": "1.5.0",
"interactive_login": True},
)
self.assertEqual(response.status_code, 200, response.text)
return {"Authorization": f"Bearer {response.json()['access_token']}"}, response.json()["account"]
def mobile_login(self, account="kefu1", password=None, headers=None, expect=200):
response = self.client.post(
"/api/v2/mobile/auth/login",
headers=headers if headers is not None else LAN,
json={"account": account, "password": password or self.zyt.users[account]["password"]},
)
self.assertEqual(response.status_code, expect, response.text)
if expect != 200:
return response
return {"Authorization": f"Bearer {response.json()['access_token']}"}
def admin_login(self):
response = self.client.post("/api/v2/auth/login", json={"username": "admin", "password": STRONG})
self.assertEqual(response.status_code, 200, response.text)
return {"Authorization": f"Bearer {response.json()['access_token']}"}
def poll(self, desktop, *, status=None, wait=0, blacklist_version=None):
body = {"wait": wait, "status": status if status is not None else {
"listening": False, "state": "stopped", "transport": "visual", "wecom_connected": True,
"pending_count": 2, "app_version": "1.5.0", "capabilities": ["commands_v1", "blacklist_sync_v1"],
}}
if blacklist_version is not None:
body["blacklist_version"] = blacklist_version
response = self.client.post("/api/v2/desktop/commands/poll", headers=desktop, json=body)
self.assertEqual(response.status_code, 200, response.text)
return response.json()
def detail(self, response):
return response.json()["detail"]
class MobileAuthTest(_Base):
def test_mobile_login_does_not_kick_the_desktop_and_tokens_are_not_interchangeable(self):
desktop, _account = self.desktop_login()
mobile = self.mobile_login()
self.assertEqual(self.client.get("/api/v2/desktop/me", headers=desktop).status_code, 200)
me = self.client.get("/api/v2/mobile/me", headers=mobile)
self.assertEqual(me.status_code, 200, me.text)
self.assertEqual(me.json()["account"]["nickname"], "客服一")
self.assertTrue(me.json()["permissions"]["control"])
self.assertFalse(me.json()["permissions"]["blacklist_remove"])
self.assertEqual(self.client.get("/api/v2/desktop/me", headers=mobile).status_code, 401)
self.assertEqual(self.client.get("/api/v2/mobile/me", headers=desktop).status_code, 401)
self.assertEqual(
self.client.post("/api/v2/desktop/commands/poll", headers=mobile, json={"wait": 0}).status_code,
401,
)
def test_mobile_and_desktop_land_on_the_same_account(self):
_desktop, desktop_account = self.desktop_login()
mobile = self.mobile_login()
self.assertEqual(
self.client.get("/api/v2/mobile/me", headers=mobile).json()["account"]["id"],
desktop_account["id"],
)
def test_wrong_password_is_generic_and_limited_per_account(self):
for _ in range(5):
response = self.mobile_login(password="wrong", expect=401)
self.assertEqual(self.detail(response)["code"], "invalid_credentials")
self.assertEqual(self.detail(response)["message"], "账号或密码错误")
calls = self.zyt.login_calls
blocked = self.mobile_login(expect=429)
self.assertEqual(self.detail(blocked)["code"], "rate_limited")
self.assertEqual(self.zyt.login_calls, calls, "被限流的尝试不能再发到 ZYT")
# 其他账号不受影响
self.mobile_login("kefu2")
def test_public_plain_http_login_is_refused(self):
public = {"X-Real-IP": "114.114.114.114"}
response = self.mobile_login(headers=public, expect=403)
self.assertEqual(self.detail(response)["code"], "https_required")
self.assertEqual(self.zyt.login_calls, 0)
self.mobile_login(headers={**public, "X-Forwarded-Proto": "https"})
# 伪造 X-Forwarded-For 不能冒充内网
spoofed = {"X-Real-IP": "114.114.114.114", "X-Forwarded-For": "10.0.0.8"}
self.assertEqual(self.detail(self.mobile_login(headers=spoofed, expect=403))["code"], "https_required")
# 代理漏配 X-Real-IP 时不放行明文登录(对端不是本机回环)
self.assertEqual(self.detail(self.mobile_login(headers={}, expect=403))["code"], "https_required")
def test_plain_http_login_is_allowed_by_default(self):
with mock.patch.dict(os.environ):
os.environ.pop("MOBILE_REQUIRE_HTTPS")
self.mobile_login(headers={"X-Real-IP": "114.114.114.114"})
self.mobile_login("kefu2", headers={})
self.assertEqual(self.zyt.login_calls, 2)
def test_zyt_lockout_is_remembered_and_not_retried(self):
self.zyt.login_error = zyt_auth.ZytLoginError(
"密码连续5次输入错误,请30分钟后重试", kind="locked", retry_after=1800)
response = self.mobile_login(expect=429)
self.assertEqual(self.detail(response)["code"], "zyt_locked")
self.zyt.login_error = None
calls = self.zyt.login_calls
again = self.mobile_login("kefu2", expect=429)
self.assertEqual(self.detail(again)["code"], "zyt_locked")
self.assertEqual(self.zyt.login_calls, calls)
def test_unsupported_terminal_and_outage_are_reported_clearly(self):
self.zyt.login_error = zyt_auth.ZytLoginError("升级", kind="terminal_unsupported")
self.assertEqual(self.detail(self.mobile_login(expect=503))["code"], "terminal_unsupported")
self.zyt.login_error = zyt_auth.ZytLoginError("超时", kind="unavailable")
self.assertEqual(self.detail(self.mobile_login(expect=502))["code"], "zyt_unavailable")
def test_logout_revokes_the_session_and_the_zyt_token(self):
mobile = self.mobile_login()
self.assertEqual(self.client.post("/api/v2/mobile/auth/logout", headers=mobile).status_code, 200)
self.assertEqual(self.client.get("/api/v2/mobile/me", headers=mobile).status_code, 401)
self.assertEqual(len(self.zyt.logged_out), 1)
def test_disabled_account_and_admin_revoke_end_mobile_sessions(self):
_desktop, account = self.desktop_login()
mobile = self.mobile_login()
admin = self.admin_login()
listing = self.client.get("/api/v2/desktop-accounts", headers=admin).json()["accounts"]
self.assertEqual(listing[0]["mobile_session_count"], 1)
revoke = self.client.post(f"/api/v2/desktop-accounts/{account['id']}/revoke-devices", headers=admin)
self.assertEqual(revoke.status_code, 200, revoke.text)
self.assertEqual(self.client.get("/api/v2/mobile/me", headers=mobile).status_code, 401)
mobile = self.mobile_login()
disabled = self.client.put(
f"/api/v2/desktop-accounts/{account['id']}", headers=admin, json={"status": "disabled"})
self.assertEqual(disabled.status_code, 200, disabled.text)
self.assertEqual(self.client.get("/api/v2/mobile/me", headers=mobile).status_code, 401)
self.assertEqual(self.detail(self.mobile_login(expect=403))["code"], "account_disabled")
def test_identity_is_rechecked_and_follows_zyt(self):
mobile = self.mobile_login()
self.zyt.users["kefu1"]["permissions"] = [CONTROL, ADD, REMOVE]
with self.db.connect() as db:
db.execute("UPDATE mobile_sessions SET identity_checked_at=0")
db.commit()
me = self.client.get("/api/v2/mobile/me", headers=mobile).json()
self.assertTrue(me["permissions"]["blacklist_remove"])
# ZYT 暂时不可用:不下线,沿用上次的权限
self.zyt.identity_error = zyt_auth.ZytLoginError("超时", kind="unavailable")
with self.db.connect() as db:
db.execute("UPDATE mobile_sessions SET identity_checked_at=0")
db.commit()
self.assertEqual(self.client.get("/api/v2/mobile/me", headers=mobile).status_code, 200)
# ZYT 会话真的失效:手机端下线
self.zyt.identity_error = None
self.zyt.tokens.clear()
with self.db.connect() as db:
db.execute("UPDATE mobile_sessions SET identity_checked_at=0")
db.commit()
self.assertEqual(self.client.get("/api/v2/mobile/me", headers=mobile).status_code, 401)
class UpstreamBudgetTest(_Base):
budget = "2"
def test_password_failures_across_accounts_pause_mobile_login(self):
self.mobile_login("kefu1", password="bad", expect=401)
self.mobile_login("kefu2", password="bad", expect=401)
response = self.mobile_login("boss", expect=429)
self.assertEqual(self.detail(response)["code"], "upstream_budget")
self.assertEqual(self.zyt.login_calls, 2)
def test_unknown_accounts_do_not_spend_the_zyt_budget(self):
# ZYT 不给"账号不存在"计数,随手乱填的账号名不应该把全体手机登录停掉。
for name in ("nobody-1", "nobody-2", "nobody-3"):
response = self.mobile_login(name, password="x", expect=401)
self.assertEqual(self.detail(response)["message"], "账号或密码错误")
self.mobile_login("kefu1")
def test_a_successful_login_resets_the_budget_like_zyt_does(self):
self.mobile_login("kefu1", password="bad", expect=401)
self.mobile_login("kefu1")
self.mobile_login("kefu2", password="bad", expect=401)
self.mobile_login("boss")
self.mobile_login("kefu2", password="bad", expect=401)
self.mobile_login("kefu2", password="bad", expect=401)
self.assertEqual(self.detail(self.mobile_login("boss", expect=429))["code"], "upstream_budget")
class ConcurrentLoginTest(_Base):
budget = "4"
def test_a_parallel_burst_cannot_outrun_the_limits(self):
"""并发的错误密码不能一起穿过限流打到 ZYT(ZYT 按出口 IP 5 次就锁)。"""
self.zyt.login_delay = 0.4
results = []
def attempt(index):
response = self.client.post(
"/api/v2/mobile/auth/login", headers={"X-Real-IP": f"192.168.1.{index + 10}"},
json={"account": "kefu1" if index % 2 else "kefu2", "password": "wrong"})
results.append(response.status_code)
workers = [threading.Thread(target=attempt, args=(index,)) for index in range(12)]
for worker in workers:
worker.start()
for worker in workers:
worker.join(30)
self.assertLessEqual(self.zyt.login_calls, 4)
self.assertEqual(results.count(401), self.zyt.login_calls)
self.assertEqual(results.count(429), 12 - self.zyt.login_calls)
def test_initial_password_accounts_are_refused_without_counting_as_failures(self):
self.zyt.users["kefu1"]["is_paw"] = 0
for _ in range(6):
response = self.mobile_login("kefu1", expect=403)
self.assertEqual(self.detail(response)["code"], "password_change_required")
self.assertEqual(self.zyt.login_calls, 6)
class RemoteControlTest(_Base):
def test_device_list_shows_live_status_from_the_command_channel(self):
desktop, _account = self.desktop_login()
mobile = self.mobile_login()
self.poll(desktop, status={
"listening": True, "state": "running", "transport": "protocol", "wecom_connected": True,
"pending_count": 3, "app_version": "1.5.0", "capabilities": ["commands_v1", "blacklist_sync_v1"],
})
items = self.client.get("/api/v2/mobile/desktops", headers=mobile).json()["items"]
self.assertEqual(len(items), 1)
device = items[0]
self.assertTrue(device["online"])
self.assertTrue(device["remote_capable"])
self.assertTrue(device["status"]["listening"])
self.assertEqual(device["status"]["state_label"], "监听中")
self.assertEqual(device["status"]["transport_label"], "协议通道")
self.assertEqual(device["status"]["pending_count"], 3)
def test_command_round_trip(self):
desktop, _account = self.desktop_login()
mobile = self.mobile_login()
self.poll(desktop)
created = self.client.post(
"/api/v2/mobile/desktops/dev-0000000000000001/commands", headers=mobile,
json={"type": "start_listening"})
self.assertEqual(created.status_code, 200, created.text)
command = created.json()["command"]
self.assertEqual(command["status"], "pending")
delivered = self.poll(desktop)["commands"]
self.assertEqual([item["id"] for item in delivered], [command["id"]])
self.assertEqual(delivered[0]["type"], "start_listening")
state = self.client.get(f"/api/v2/mobile/commands/{command['id']}", headers=mobile).json()["command"]
self.assertEqual(state["status"], "delivered")
ack = self.client.post(
f"/api/v2/desktop/commands/{command['id']}/ack", headers=desktop,
json={"ok": True, "result": {"listening": True}, "status": {
"listening": True, "state": "running", "transport": "visual", "wecom_connected": True,
"pending_count": 0, "app_version": "1.5.0", "capabilities": ["commands_v1"]}})
self.assertEqual(ack.status_code, 200, ack.text)
state = self.client.get(f"/api/v2/mobile/commands/{command['id']}", headers=mobile).json()["command"]
self.assertEqual(state["status"], "succeeded")
self.assertTrue(state["result"]["listening"])
# 回执里带的状态立刻反映到手机端,不用等下一次长轮询
device = self.client.get("/api/v2/mobile/desktops", headers=mobile).json()["items"][0]
self.assertTrue(device["status"]["listening"])
self.assertEqual(device["last_command"]["status"], "succeeded")
self.assertEqual(self.poll(desktop)["commands"], [])
actions = [row["action"] for row in self.db.audit_entries(50)]
self.assertIn("remote.command.create", actions)
self.assertIn("remote.command.ack", actions)
def test_unacknowledged_commands_are_redelivered_and_stale_ones_superseded(self):
desktop, _account = self.desktop_login()
mobile = self.mobile_login()
self.poll(desktop)
url = "/api/v2/mobile/desktops/dev-0000000000000001/commands"
first = self.client.post(url, headers=mobile, json={"type": "stop_listening"}).json()["command"]
second = self.client.post(url, headers=mobile, json={"type": "start_listening"}).json()["command"]
state = self.client.get(f"/api/v2/mobile/commands/{first['id']}", headers=mobile).json()["command"]
self.assertEqual(state["status"], "superseded")
self.assertEqual([item["id"] for item in self.poll(desktop)["commands"]], [second["id"]])
# 没回执就再给一次,由电脑按 id 去重
self.assertEqual([item["id"] for item in self.poll(desktop)["commands"]], [second["id"]])
def test_commands_expire_after_ten_minutes(self):
desktop, _account = self.desktop_login()
mobile = self.mobile_login()
self.poll(desktop)
command = self.client.post(
"/api/v2/mobile/desktops/dev-0000000000000001/commands", headers=mobile,
json={"type": "stop_listening"}).json()["command"]
self.assertGreaterEqual(command["expires_at"] - time.time(), 590)
with self.db.connect() as db:
db.execute("UPDATE desktop_commands SET expires_at=?", (int(time.time()) - 1,))
db.commit()
self.assertEqual(self.poll(desktop)["commands"], [])
state = self.client.get(f"/api/v2/mobile/commands/{command['id']}", headers=mobile).json()["command"]
self.assertEqual(state["status"], "expired")
late = self.client.post(f"/api/v2/desktop/commands/{command['id']}/ack", headers=desktop,
json={"ok": True})
self.assertEqual(late.json()["command"]["status"], "expired")
def test_long_poll_wakes_up_as_soon_as_a_command_arrives(self):
desktop, _account = self.desktop_login()
mobile = self.mobile_login()
self.poll(desktop)
result = {}
def wait_for_command():
started = time.monotonic()
result["body"] = self.poll(desktop, wait=20)
result["elapsed"] = time.monotonic() - started
worker = threading.Thread(target=wait_for_command)
worker.start()
time.sleep(0.6)
created = self.client.post(
"/api/v2/mobile/desktops/dev-0000000000000001/commands", headers=mobile,
json={"type": "stop_listening"})
self.assertEqual(created.status_code, 200, created.text)
worker.join(15)
self.assertFalse(worker.is_alive())
self.assertEqual(len(result["body"]["commands"]), 1)
self.assertLess(result["elapsed"], 5)
def test_offline_and_outdated_clients_cannot_be_commanded(self):
desktop, _account = self.desktop_login()
mobile = self.mobile_login()
url = "/api/v2/mobile/desktops/dev-0000000000000001/commands"
# 旧版客户端:有心跳,没有命令通道
self.client.get("/api/v2/desktop/me", headers=desktop)
device = self.client.get("/api/v2/mobile/desktops", headers=mobile).json()["items"][0]
self.assertTrue(device["online"])
self.assertFalse(device["remote_capable"])
response = self.client.post(url, headers=mobile, json={"type": "start_listening"})
self.assertEqual(response.status_code, 409)
self.assertEqual(self.detail(response)["code"], "client_outdated")
# 程序没开:心跳停了
self.poll(desktop)
with self.db.connect() as db:
db.execute("UPDATE desktop_devices SET last_seen_at='2020-01-01T00:00:00+08:00'")
db.commit()
response = self.client.post(url, headers=mobile, json={"type": "start_listening"})
self.assertEqual(response.status_code, 409)
self.assertEqual(self.detail(response)["code"], "device_offline")
self.assertEqual(
self.client.post(url.replace("0001", "9999"), headers=mobile,
json={"type": "start_listening"}).status_code, 404)
def test_permissions_and_account_scope(self):
desktop1, account1 = self.desktop_login("kefu1", "dev-0000000000000001")
self.poll(desktop1)
_desktop2, account2 = self.desktop_login("kefu2", "dev-0000000000000002")
kefu2 = self.mobile_login("kefu2")
# 没有 remote/control:能看不能控
self.assertEqual(self.client.get("/api/v2/mobile/desktops", headers=kefu2).status_code, 200)
response = self.client.post(
"/api/v2/mobile/desktops/dev-0000000000000002/commands", headers=kefu2,
json={"type": "start_listening"})
self.assertEqual(response.status_code, 403)
# 不能看别人的电脑,也不能冒用别人的设备 id
self.assertEqual(self.client.get(
f"/api/v2/mobile/desktops?account_id={account1['id']}", headers=kefu2).status_code, 403)
kefu1 = self.mobile_login("kefu1")
self.assertEqual(self.client.post(
"/api/v2/mobile/desktops/dev-0000000000000002/commands", headers=kefu1,
json={"type": "start_listening"}).status_code, 404)
self.assertEqual(self.client.get("/api/v2/mobile/accounts", headers=kefu1).status_code, 403)
# 主管(remote/all):能看全部、能控别人
boss = self.mobile_login("boss")
accounts = self.client.get("/api/v2/mobile/accounts", headers=boss).json()["items"]
self.assertEqual({item["id"] for item in accounts} >= {account1["id"], account2["id"]}, True)
created = self.client.post(
f"/api/v2/mobile/desktops/dev-0000000000000001/commands?account_id={account1['id']}",
headers=boss, json={"type": "stop_listening"})
self.assertEqual(created.status_code, 200, created.text)
self.assertEqual(self.poll(desktop1)["commands"][0]["created_by"], "主管(手机)")
# 未知指令
self.assertEqual(self.client.post(
"/api/v2/mobile/desktops/dev-0000000000000001/commands", headers=kefu1,
json={"type": "shutdown"}).status_code, 400)
class BlacklistTest(_Base):
def seed_archive(self, desktop):
payload = {
"source_account": {"external_account_id": "1688850000000001", "display_name": "客服一企微"},
"source_table": "message_table",
"checkpoint": {"rowid": 3},
"messages": [
{
"source_message_id": f"m-{index}", "server_id": f"s-{index}",
"conversation": {"external_id": external_id, "name": name},
"sender": {"external_id": "7881000000000001", "display_name": name,
"identity_type": "wecom_local_uid", "scope_id": "1688850000000001"},
"sent_at": 1_758_000_000_000 + index * 1000,
"message_type": "text", "direction": "inbound", "content": "正文不应出现在手机端",
}
for index, (external_id, name) in enumerate(
(("S:1688850000000001_7881000000000001", "客户张三"),
("R:10696052955016166", "售后群"),
("S:1688850000000001_7881000000000002", "客户李四")), start=1)
],
}
response = self.client.post("/api/v2/archive/desktop/imports/messages", headers=desktop, json=payload)
self.assertEqual(response.status_code, 200, response.text)
def test_conversations_carry_blacklist_state_and_never_message_bodies(self):
desktop, _account = self.desktop_login()
self.seed_archive(desktop)
mobile = self.mobile_login()
listing = self.client.get("/api/v2/mobile/conversations", headers=mobile)
self.assertEqual(listing.status_code, 200, listing.text)
items = listing.json()["items"]
self.assertEqual([item["name"] for item in items], ["客户李四", "售后群", "客户张三"])
self.assertNotIn("正文不应出现在手机端", listing.text)
self.assertEqual(items[1]["type_label"], "群聊")
target = items[0]
added = self.client.put(
f"/api/v2/mobile/blacklist/{target['account']}/{target['conversation']}",
headers=mobile, json={"reason": "客户投诉"})
self.assertEqual(added.status_code, 200, added.text)
self.assertEqual(added.json()["entry"]["name"], "客户李四")
self.assertEqual(added.json()["entry"]["account_name"], "客服一企微")
self.assertEqual(added.json()["entry"]["created_by"], "客服一(手机)")
searched = self.client.get("/api/v2/mobile/conversations?q=李四", headers=mobile).json()
self.assertEqual(len(searched["items"]), 1)
self.assertTrue(searched["items"][0]["blocked"])
self.assertEqual(searched["items"][0]["blacklist"]["reason"], "客户投诉")
blocked = self.client.get("/api/v2/mobile/conversations?scope=blocked", headers=mobile).json()
self.assertEqual(blocked["blocked_total"], 1)
self.assertEqual(blocked["items"][0]["conversation"], target["conversation"])
# 其他客服看不到这个租户的会话
other = self.mobile_login("kefu2")
self.assertEqual(self.client.get("/api/v2/mobile/conversations", headers=other).json()["items"], [])
def test_blacklist_permissions(self):
_desktop, account = self.desktop_login()
kefu2 = self.mobile_login("kefu2")
path = "/api/v2/mobile/blacklist/1688850000000001/S:1688850000000001_7881000000000009"
self.assertEqual(self.client.put(path, headers=kefu2, json={}).status_code, 403)
kefu1 = self.mobile_login("kefu1")
first = self.client.put(path, headers=kefu1, json={"name": "客户王五"})
self.assertEqual(first.status_code, 200, first.text)
removed = self.client.delete(path, headers=kefu1)
self.assertEqual(removed.status_code, 403)
self.assertIn(REMOVE, self.detail(removed)["message"])
boss = self.mobile_login("boss")
by_boss = self.client.delete(f"{path}?account_id={account['id']}", headers=boss)
self.assertEqual(by_boss.status_code, 200, by_boss.text)
self.assertGreater(by_boss.json()["version"], first.json()["version"])
self.assertEqual(self.client.delete(f"{path}?account_id={account['id']}", headers=boss).status_code, 404)
actions = [row["action"] for row in self.db.audit_entries(50)]
self.assertIn("blacklist.add", actions)
self.assertIn("blacklist.remove", actions)
def test_desktop_sync_is_the_write_through_path_and_polls_see_the_version(self):
desktop, _account = self.desktop_login()
mobile = self.mobile_login()
start = self.poll(desktop)["blacklist_version"]
synced = self.client.post("/api/v2/desktop/blacklist/sync", headers=desktop, json={"add": [
{"account": "1688850000000001", "conversation": "S:legacy-1", "name": "老客户", "account_name": "企微"},
{"account": "1688850000000001", "conversation": "S:legacy-1", "name": "老客户"},
{"account": "", "conversation": "S:bad"},
]})
self.assertEqual(synced.status_code, 200, synced.text)
body = synced.json()
self.assertEqual([item["conversation"] for item in body["items"]], ["S:legacy-1"])
self.assertEqual(len(body["rejected"]), 1)
self.assertGreater(body["version"], start)
# 版本不一致的长轮询立即返回,让电脑去同步
began = time.monotonic()
self.assertEqual(self.poll(desktop, wait=10, blacklist_version=start)["blacklist_version"], body["version"])
self.assertLess(time.monotonic() - began, 3)
pulled = self.client.get("/api/v2/desktop/blacklist", headers=desktop).json()
self.assertEqual(pulled["version"], body["version"])
# 桌面端没有 remove 权限:移出被拒;主管有
denied = self.client.delete("/api/v2/desktop/blacklist/1688850000000001/S:legacy-1", headers=desktop)
self.assertEqual(denied.status_code, 403)
self.zyt.users["kefu1"]["permissions"].append(REMOVE)
desktop, _account = self.desktop_login()
allowed = self.client.delete("/api/v2/desktop/blacklist/1688850000000001/S:legacy-1", headers=desktop)
self.assertEqual(allowed.status_code, 200, allowed.text)
self.assertEqual(self.client.get("/api/v2/desktop/blacklist", headers=desktop).json()["items"], [])
# 手机加的黑名单,电脑下一次长轮询就能看到版本变化
version = self.poll(desktop)["blacklist_version"]
self.client.put("/api/v2/mobile/blacklist/1688850000000001/S:from-phone", headers=mobile, json={})
self.assertGreater(self.poll(desktop, blacklist_version=version)["blacklist_version"], version)
def test_re_adding_a_removed_entry_reactivates_it(self):
desktop, account = self.desktop_login()
boss = self.mobile_login("boss")
path = f"/api/v2/mobile/blacklist/1688850000000001/S:again?account_id={account['id']}"
self.assertEqual(self.client.put(path, headers=boss, json={"reason": "一"}).status_code, 200)
self.assertEqual(self.client.delete(path, headers=boss).status_code, 200)
again = self.client.put(path, headers=boss, json={"reason": "二"})
self.assertEqual(again.status_code, 200)
items = self.client.get("/api/v2/desktop/blacklist", headers=desktop).json()["items"]
self.assertEqual([(item["conversation"], item["reason"]) for item in items], [("S:again", "二")])
class MobileHostingTest(_Base):
def test_mobile_bundle_is_served_under_m_with_safe_fallback(self):
dist = self.root / "mobile-dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text("<html>mobile</html>", encoding="utf-8")
(dist / "assets" / "app-abc123.js").write_text("console.log(1)", encoding="utf-8")
(self.root / "secret.txt").write_text("secret", encoding="utf-8")
with mock.patch.object(admin_api, "_mobile_dist", return_value=dist):
client = TestClient(admin_api.create_app(self.root / "t.db", zyt_mobile_auth=self.zyt))
with client:
entry = client.get("/m/")
self.assertIn("mobile", entry.text)
self.assertEqual(entry.headers["cache-control"], "no-cache")
self.assertEqual(entry.headers["x-frame-options"], "DENY")
self.assertIn("frame-ancestors 'none'", entry.headers["content-security-policy"])
asset = client.get("/m/assets/app-abc123.js")
self.assertEqual(asset.status_code, 200)
self.assertIn("immutable", asset.headers["cache-control"])
# 发版后旧页面请求旧 chunk:必须是 404,不能回 index.html
self.assertEqual(client.get("/m/assets/app-old999.js").status_code, 404)
self.assertIn("mobile", client.get("/m/devices/abc").text)
self.assertNotIn("secret", client.get("/m/..%2Fsecret.txt").text)
self.assertEqual(client.get("/m", follow_redirects=False).status_code, 308)
self.assertEqual(client.get("/api/v2/health").status_code, 200)
class ZytMobileClientTest(TestCase):
def response(self, payload):
return io.BytesIO(json.dumps(payload).encode("utf-8"))
def login(self, payload):
with mock.patch.object(zyt_auth.urllib.request, "urlopen", return_value=self.response(payload)) as opened:
token = zyt_auth.login_admin("kefu1", "secret", api_url="https://zyt.example.test")
request = opened.call_args.args[0]
self.assertEqual(json.loads(request.data)["terminal"], 9)
return token
def failure(self, message):
with self.assertRaises(zyt_auth.ZytLoginError) as caught:
self.login({"code": 0, "msg": message, "data": []})
return caught.exception
def test_login_returns_the_terminal_9_token(self):
self.assertEqual(self.login({"code": 1, "data": {"token": "abc"}}), "abc")
def test_initial_password_accounts_are_refused_and_logged_out(self):
with mock.patch.object(zyt_auth.urllib.request, "urlopen", side_effect=[
self.response({"code": 1, "data": {"token": "fresh", "is_paw": 0}}),
self.response({"code": 1, "msg": ""}),
]) as opened:
with self.assertRaises(zyt_auth.ZytLoginError) as caught:
zyt_auth.login_admin("kefu1", "secret", api_url="https://zyt.example.test")
self.assertEqual(caught.exception.kind, "password_change_required")
logout = opened.call_args_list[1].args[0]
self.assertTrue(logout.full_url.endswith("/adminapi/login/logout"))
self.assertEqual(logout.headers.get("Token"), "fresh")
def test_login_failures_are_classified(self):
self.assertEqual(self.failure("密码错误").kind, "credentials")
self.assertEqual(self.failure("账号不存在").kind, "unknown_account")
self.assertEqual(str(self.failure("账号不存在")), "账号或密码错误")
locked = self.failure("密码连续5次输入错误,请30分钟后重试")
self.assertEqual((locked.kind, locked.retry_after), ("locked", 1800))
self.assertEqual(self.failure("terminal必须在 1,2,7 范围内").kind, "terminal_unsupported")
self.assertEqual(self.failure("账号已禁用").kind, "disabled")
def test_transport_errors_are_unavailable(self):
with mock.patch.object(zyt_auth.urllib.request, "urlopen",
side_effect=urllib.error.URLError("timed out")):
with self.assertRaises(zyt_auth.ZytLoginError) as caught:
zyt_auth.login_admin("kefu1", "secret", api_url="https://zyt.example.test")
self.assertEqual(caught.exception.kind, "unavailable")
def test_session_must_be_the_mobile_terminal(self):
def verify(data):
with mock.patch.object(zyt_auth.urllib.request, "urlopen", return_value=self.response(data)):
return zyt_auth.verify_admin_session("tok", api_url="https://zyt.example.test")
identity = verify({"code": 1, "data": {"user_id": "admin:9", "terminal": 9, "status": "active",
"root": 0, "permissions": [CONTROL]}})
self.assertEqual(identity["permissions"], [CONTROL])
for other in (7, 8): # 桌面端令牌、ZYT 的 AI 浏览器令牌都不能当手机令牌用
with self.assertRaises(zyt_auth.ZytLoginError) as caught:
verify({"code": 1, "data": {"user_id": "admin:9", "terminal": other, "status": "active"}})
self.assertEqual(caught.exception.kind, "terminal_unsupported")
with self.assertRaises(zyt_auth.ZytLoginError) as caught:
verify({"code": 0, "msg": "当前登录令牌不属于企业微信客服桌面端"})
self.assertEqual(caught.exception.kind, "terminal_unsupported")
with self.assertRaises(zyt_auth.ZytLoginError) as caught:
verify({"code": 0, "msg": "登录已失效,请重新登录"})
self.assertEqual(caught.exception.kind, "rejected")
if __name__ == "__main__":
main()