724 lines
38 KiB
Python
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()
|