""" MQTT Broker 认证集成测试 验证 [.mqtt].username/password 配置生效: A. 内嵌 Broker 启用认证(FileAuthPlugin): 1. 正确凭据 → 连接/订阅/发布正常 2. 错误密码 → 连接被拒(ConnectError) 3. 匿名连接 → 连接被拒 B. 未配置凭据(默认)→ 匿名连接正常(原行为) C. MQTTBridge 客户端凭据传递(含特殊字符密码的 URI 编码) """ import asyncio import json 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 json.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())