Files
chongming-core/test/test_mqtt_auth.py
T
2026-08-23 05:10:27 +08:00

171 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
MQTT Broker 认证集成测试
验证 [<env>.mqtt].username/password 配置生效:
A. 内嵌 Broker 启用认证(FileAuthPlugin):
1. 正确凭据 → 连接/订阅/发布正常
2. 错误密码 → 连接被拒(ConnectError)
3. 匿名连接 → 连接被拒
B. 未配置凭据(默认)→ 匿名连接正常(原行为)
C. MQTTBridge 客户端凭据传递(含特殊字符密码的 URI 编码)
"""
import asyncio
import orjson
import multiprocessing
import socket
import sys
import time
from pathlib import Path
from urllib.parse import quote
sys.path.insert(0, str(Path(__file__).parent))
from amqtt.client import MQTTClient
from amqtt.errors import ConnectError
from src.chongming.app.core.lifespan_setup import _run_mqtt_broker
from src.chongming.app.core.mqtt_bridge import MQTTBridge
HOST = "127.0.0.1"
USERNAME = "cm_user"
PASSWORD = "p@ss w0rd!%#"
def cred_uri(port: int, user: str | None = None, pwd: str | None = None) -> str:
"""构建带凭据的 Broker URI(百分号编码,与 MQTTBridge._broker_uri 同语义)"""
if user:
return f"mqtt://{quote(user, safe='')}:{quote(pwd or '', safe='')}@{HOST}:{port}/"
return f"mqtt://{HOST}:{port}/"
def free_port() -> int:
with socket.socket() as s:
s.bind((HOST, 0))
return s.getsockname()[1]
def wait_port(port: int, timeout: float = 20.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket() as s:
s.settimeout(0.5)
if s.connect_ex((HOST, port)) == 0:
return True
time.sleep(0.1)
return False
def start_broker(port: int, username=None, password=None):
proc = multiprocessing.Process(
target=_run_mqtt_broker, args=(HOST, port, username, password), daemon=True
)
proc.start()
assert wait_port(port), f"Broker 未在 {port} 端口就绪"
return proc
async def try_connect(uri: str, expect_success: bool, label: str):
client = MQTTClient(config={"auto_reconnect": False})
try:
await client.connect(uri)
except ConnectError as e:
assert not expect_success, f"{label}: 应连接成功,实际被拒 ({e})"
return None
assert expect_success, f"{label}: 应被拒绝,实际连接成功"
return client
async def scenario_auth(port: int):
"""配置了账号密码 → 正确凭据通过,错误/匿名被拒"""
proc = start_broker(port, USERNAME, PASSWORD)
try:
# 1. 正确凭据
c = await try_connect(cred_uri(port, USERNAME, PASSWORD), True, "正确凭据")
await c.subscribe([("auth/t/#", 1)])
await c.publish("auth/t/1", b"hello", 1)
m = await asyncio.wait_for(c.deliver_message(), timeout=10)
assert m.data == b"hello", f"收到数据异常: {m.data}"
await c.disconnect()
print("PASS [A1] 正确凭据: 连接/订阅/发布/接收正常")
# 2. 错误密码
await try_connect(cred_uri(port, USERNAME, "wrong"), False, "错误密码")
print("PASS [A2] 错误密码: 连接被拒")
# 3. 匿名
await try_connect(cred_uri(port), False, "匿名连接")
print("PASS [A3] 匿名连接: 连接被拒")
finally:
proc.terminate()
proc.join(timeout=5)
async def scenario_anonymous(port: int):
"""未配置凭据(默认)→ 匿名可用"""
proc = start_broker(port)
try:
c = await try_connect(cred_uri(port), True, "匿名连接(默认)")
await c.disconnect()
print("PASS [B] 默认匿名模式: 匿名连接正常")
finally:
proc.terminate()
proc.join(timeout=5)
class _FakeWorkerManager:
async def call(self, *a, **kw):
return {"ok": True}
async def scenario_bridge(port: int):
"""MQTTBridge 客户端携带凭据(特殊字符密码经 URI 百分号编码)"""
proc = start_broker(port, USERNAME, PASSWORD)
sub = None
try:
sub = MQTTClient(config={"auto_reconnect": False})
await sub.connect(cred_uri(port, USERNAME, PASSWORD))
await sub.subscribe([("chongming/#", 1)])
bridge = MQTTBridge(worker_manager=_FakeWorkerManager(),
host=HOST, port=port,
username=USERNAME, password=PASSWORD)
await bridge.start()
assert bridge.is_running, "Bridge 未连接"
# 发布请求 → Bridge 路由到 worker → 响应回发(验证完整链路带认证)
pub = MQTTClient(config={"auto_reconnect": False})
await pub.connect(cred_uri(port, USERNAME, PASSWORD))
await pub.subscribe([("chongming/demo/op/response", 1)])
await pub.publish(
"chongming/demo/op",
b'{"meta": {"timeout": 10}, "data": {"x": 1}}', 1)
m = await asyncio.wait_for(sub.deliver_message(), timeout=10)
assert m.topic == "chongming/demo/op", f"未收到请求: {m.topic}"
m2 = await asyncio.wait_for(sub.deliver_message(), timeout=10)
assert m2.topic == "chongming/demo/op/response", f"未收到响应: {m2.topic}"
assert orjson.loads(m2.data) == {"ok": True}, f"响应内容异常: {m2.data}"
await bridge.stop()
await pub.disconnect()
print("PASS [C] MQTTBridge 凭据传递: 连接/路由/响应链路正常(含特殊字符密码)")
finally:
if sub and sub.session is not None:
try:
await sub.disconnect()
except Exception:
pass
proc.terminate()
proc.join(timeout=5)
async def main():
p1, p2, p3 = free_port(), free_port(), free_port()
await scenario_auth(p1)
await scenario_anonymous(p2)
await scenario_bridge(p3)
print("\n全部通过 ✔")
if __name__ == "__main__":
multiprocessing.set_start_method("fork", force=True)
asyncio.run(main())