171 lines
5.8 KiB
Python
171 lines
5.8 KiB
Python
"""
|
||
MQTT Broker 认证集成测试
|
||
|
||
验证 [<env>.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())
|