Authenticate the node dashboard, and the broker connection per node
Two unauthenticated surfaces closed on the edge node. Dashboard and API: the local dashboard and every /api/* route were open to anything on the node's LAN. Adds a login page plus session-cookie auth for the browser, and cookie-or-Basic for the API so scripted callers stay possible. Passwords are hashed with stdlib scrypt (no new dependency, this runs on a Pi) and compared in constant time; the salt and session-signing secret persist in credentials.json. Startup warns while the default password is still in place. No non-browser callers of the node API exist today (C2 talks to nodes over MQTT and nodes call C2 outbound), so nothing breaks. Adds python-multipart, which FastAPI's Form() needs for the login POST and which was missing from requirements entirely. MQTT: nodes authenticated with a shared drb-node password, and the broker ACL keyed off %c — the client-supplied client id — so any holder of that one password could claim another node's topic namespace. Nodes now connect as username=<node_id>, password=<their C2-issued api_key>, which mosquitto's dynamic-security plugin checks, with the ACL keyed off the authenticated %u. TLS is gated on MQTT_TLS and uses default CA verification. The old key_request MQTT path stays in place behind TODO(mqtt-cutover) markers as the fallback until the cutover is proven; a node with no api_key on disk logs a clear repeated refusal rather than spinning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a61a7b2c31
commit
87633ab50d
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Unit tests for local dashboard/API auth (app.internal.auth), plus the
|
||||
credentials.py additions that persist its signing material (auth_salt,
|
||||
session_secret) alongside the existing node API key.
|
||||
|
||||
This file is pure logic: password hashing/constant-time comparison, session
|
||||
token signing/expiry, and HTTP Basic header parsing. See test_auth_endpoints.py
|
||||
for the HTTP-level login/redirect/protection round trip through the routers.
|
||||
"""
|
||||
import base64
|
||||
import secrets
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import auth, credentials
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_credentials(tmp_path, monkeypatch):
|
||||
"""Every test gets a fresh, on-disk-isolated credentials store so the
|
||||
generated auth salt / session secret never leak between tests, and a
|
||||
known username/password instead of the shipped default."""
|
||||
creds_file = tmp_path / "credentials.json"
|
||||
monkeypatch.setattr(credentials, "_CREDS_FILE", creds_file)
|
||||
monkeypatch.setattr(credentials, "_api_key", None)
|
||||
monkeypatch.setattr(credentials, "_auth_salt", None)
|
||||
monkeypatch.setattr(credentials, "_session_secret", None)
|
||||
monkeypatch.setattr(settings, "dashboard_username", "tester")
|
||||
monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass")
|
||||
yield
|
||||
|
||||
|
||||
def _basic_header(username: str, password: str) -> str:
|
||||
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
return f"Basic {encoded}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# credentials.py: auth salt / session secret generation + persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_auth_material_is_generated_on_first_access():
|
||||
salt = credentials.get_auth_salt()
|
||||
secret = credentials.get_session_secret()
|
||||
assert isinstance(salt, bytes) and len(salt) == 16
|
||||
assert isinstance(secret, bytes) and len(secret) == 32
|
||||
|
||||
|
||||
def test_auth_material_is_stable_across_repeated_calls():
|
||||
assert credentials.get_auth_salt() == credentials.get_auth_salt()
|
||||
assert credentials.get_session_secret() == credentials.get_session_secret()
|
||||
|
||||
|
||||
def test_auth_material_persists_to_disk_and_survives_reload():
|
||||
salt = credentials.get_auth_salt()
|
||||
secret = credentials.get_session_secret()
|
||||
|
||||
# Simulate a container restart: drop in-memory state, reload from disk.
|
||||
credentials._api_key = None
|
||||
credentials._auth_salt = None
|
||||
credentials._session_secret = None
|
||||
credentials.load()
|
||||
|
||||
assert credentials.get_auth_salt() == salt
|
||||
assert credentials.get_session_secret() == secret
|
||||
|
||||
|
||||
def test_saving_api_key_does_not_clobber_auth_material():
|
||||
"""save_api_key() used to json.dumps({"api_key": key}) directly, which
|
||||
would have wiped auth_salt/session_secret out of credentials.json the
|
||||
moment C2 provisioned an API key after this feature was added."""
|
||||
salt = credentials.get_auth_salt()
|
||||
secret = credentials.get_session_secret()
|
||||
|
||||
credentials.save_api_key("some-node-api-key")
|
||||
|
||||
assert credentials.get_api_key() == "some-node-api-key"
|
||||
assert credentials.get_auth_salt() == salt
|
||||
assert credentials.get_session_secret() == secret
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_credentials() — password hashing + constant-time compare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_verify_credentials_accepts_correct_username_and_password():
|
||||
assert auth.verify_credentials("tester", "s3cret-pass") is True
|
||||
|
||||
|
||||
def test_verify_credentials_rejects_wrong_password():
|
||||
assert auth.verify_credentials("tester", "wrong") is False
|
||||
|
||||
|
||||
def test_verify_credentials_rejects_wrong_username():
|
||||
assert auth.verify_credentials("someone-else", "s3cret-pass") is False
|
||||
|
||||
|
||||
def test_verify_credentials_rejects_empty_password():
|
||||
assert auth.verify_credentials("tester", "") is False
|
||||
|
||||
|
||||
def test_password_is_hashed_not_compared_in_plaintext():
|
||||
with patch.object(auth, "_hash_password", wraps=auth._hash_password) as spy:
|
||||
auth.verify_credentials("tester", "s3cret-pass")
|
||||
# Once for the configured password, once for the submitted one — neither
|
||||
# side is ever compared as a raw string.
|
||||
assert spy.call_count == 2
|
||||
|
||||
|
||||
def test_is_using_default_password_detects_the_shipped_default(monkeypatch):
|
||||
monkeypatch.setattr(settings, "dashboard_password", auth.DEFAULT_PASSWORD)
|
||||
assert auth.is_using_default_password() is True
|
||||
|
||||
|
||||
def test_is_using_default_password_false_once_changed():
|
||||
assert auth.is_using_default_password() is False # fixture already changed it
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# session tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_session_token_round_trips():
|
||||
token = auth.create_session_token("tester")
|
||||
assert auth._verify_session_token(token) == "tester"
|
||||
|
||||
|
||||
def test_session_token_rejects_tampered_payload():
|
||||
token = auth.create_session_token("tester")
|
||||
tampered = ("X" if token[0] != "X" else "Y") + token[1:]
|
||||
assert auth._verify_session_token(tampered) is None
|
||||
|
||||
|
||||
def test_session_token_rejects_expired_token(monkeypatch):
|
||||
token = auth.create_session_token("tester")
|
||||
future = time.time() + auth.SESSION_TTL_SECONDS + 1
|
||||
monkeypatch.setattr(time, "time", lambda: future)
|
||||
assert auth._verify_session_token(token) is None
|
||||
|
||||
|
||||
def test_session_token_rejects_username_mismatch(monkeypatch):
|
||||
token = auth.create_session_token("tester")
|
||||
monkeypatch.setattr(settings, "dashboard_username", "someone-else")
|
||||
assert auth._verify_session_token(token) is None
|
||||
|
||||
|
||||
def test_session_token_garbage_input_does_not_raise():
|
||||
assert auth._verify_session_token("not-a-real-token") is None
|
||||
assert auth._verify_session_token("") is None
|
||||
|
||||
|
||||
def test_session_token_signed_with_a_different_secret_is_rejected():
|
||||
token = auth.create_session_token("tester")
|
||||
# As if the node restarted without a persisted credentials.json.
|
||||
credentials._session_secret = secrets.token_bytes(32)
|
||||
assert auth._verify_session_token(token) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP Basic parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_basic_auth_accepts_valid_header():
|
||||
assert auth._verify_basic_auth(_basic_header("tester", "s3cret-pass")) is True
|
||||
|
||||
|
||||
def test_basic_auth_rejects_wrong_credentials():
|
||||
assert auth._verify_basic_auth(_basic_header("tester", "wrong")) is False
|
||||
|
||||
|
||||
def test_basic_auth_rejects_non_basic_scheme():
|
||||
assert auth._verify_basic_auth("Bearer sometoken") is False
|
||||
|
||||
|
||||
def test_basic_auth_tolerates_garbage_without_raising():
|
||||
assert auth._verify_basic_auth("Basic not-valid-base64!!") is False
|
||||
assert auth._verify_basic_auth("") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_authenticated() — the combined check require_auth is built on
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_authenticated_true_with_valid_session_cookie():
|
||||
token = auth.create_session_token("tester")
|
||||
assert auth.is_authenticated(token, None) is True
|
||||
|
||||
|
||||
def test_is_authenticated_true_with_valid_basic_header():
|
||||
assert auth.is_authenticated(None, _basic_header("tester", "s3cret-pass")) is True
|
||||
|
||||
|
||||
def test_is_authenticated_false_with_neither():
|
||||
assert auth.is_authenticated(None, None) is False
|
||||
|
||||
|
||||
def test_is_authenticated_false_with_invalid_session_and_no_header():
|
||||
assert auth.is_authenticated("garbage", None) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI dependencies: require_session / require_auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_require_session_false_with_no_cookie():
|
||||
assert await auth.require_session(None) is False
|
||||
|
||||
|
||||
async def test_require_session_true_with_valid_cookie():
|
||||
token = auth.create_session_token("tester")
|
||||
assert await auth.require_session(token) is True
|
||||
|
||||
|
||||
async def test_require_auth_raises_401_with_no_credentials():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await auth.require_auth(None, None)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.headers["WWW-Authenticate"] == "Basic"
|
||||
|
||||
|
||||
async def test_require_auth_passes_with_valid_session_cookie():
|
||||
token = auth.create_session_token("tester")
|
||||
await auth.require_auth(token, None) # must not raise
|
||||
|
||||
|
||||
async def test_require_auth_passes_with_valid_basic_header():
|
||||
await auth.require_auth(None, _basic_header("tester", "s3cret-pass")) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# startup warning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_warn_if_default_password_logs_when_default(monkeypatch):
|
||||
monkeypatch.setattr(settings, "dashboard_password", auth.DEFAULT_PASSWORD)
|
||||
with patch("app.internal.auth.logger") as mock_logger:
|
||||
auth.warn_if_default_password()
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
def test_warn_if_default_password_silent_once_changed():
|
||||
with patch("app.internal.auth.logger") as mock_logger:
|
||||
auth.warn_if_default_password()
|
||||
mock_logger.warning.assert_not_called()
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
HTTP-level tests for the auth-protected dashboard/API surface: login/logout
|
||||
flow, session-cookie protection of the HTML pages, and Basic-auth protection
|
||||
of the JSON API.
|
||||
|
||||
Built as a standalone FastAPI app assembling the real api/ui routers — NOT
|
||||
app.main:app, which wires a lifespan that connects to MQTT, starts the
|
||||
PulseAudio capture loop, and pings OP25/C2. None of that belongs in a unit
|
||||
test, and none of it is needed to exercise the auth layer: the auth
|
||||
dependency runs (and short-circuits with a redirect/401) before any route
|
||||
body that would touch those services.
|
||||
"""
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import auth, credentials
|
||||
from app.routers import api, ui
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_credentials(tmp_path, monkeypatch):
|
||||
creds_file = tmp_path / "credentials.json"
|
||||
monkeypatch.setattr(credentials, "_CREDS_FILE", creds_file)
|
||||
monkeypatch.setattr(credentials, "_api_key", None)
|
||||
monkeypatch.setattr(credentials, "_auth_salt", None)
|
||||
monkeypatch.setattr(credentials, "_session_secret", None)
|
||||
monkeypatch.setattr(settings, "dashboard_username", "tester")
|
||||
monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(api.router)
|
||||
test_app.include_router(ui.router)
|
||||
with TestClient(test_app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _basic_header(username: str, password: str) -> dict:
|
||||
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/* — machine-facing JSON API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_api_route_rejects_unauthenticated_requests(client):
|
||||
r = client.get("/api/status", follow_redirects=False)
|
||||
assert r.status_code == 401
|
||||
assert r.headers["www-authenticate"] == "Basic"
|
||||
|
||||
|
||||
def test_api_route_accepts_valid_basic_auth(client):
|
||||
r = client.get("/api/config", headers=_basic_header("tester", "s3cret-pass"))
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_api_route_rejects_wrong_basic_auth_password(client):
|
||||
r = client.get("/api/config", headers=_basic_header("tester", "wrong"))
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_api_route_accepts_dashboard_session_cookie(client):
|
||||
login = client.post(
|
||||
"/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False
|
||||
)
|
||||
assert login.status_code == 303
|
||||
assert auth.SESSION_COOKIE_NAME in login.cookies
|
||||
|
||||
r = client.get("/api/config") # cookie jar carries the session cookie
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_every_api_route_is_registered_behind_the_auth_dependency():
|
||||
"""Structural guard: catches a future route added to api.py that forgets
|
||||
the router is meant to protect everything in it."""
|
||||
assert any(
|
||||
getattr(dep, "dependency", None) is auth.require_auth
|
||||
for dep in api.router.dependencies
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# / and /scanner — the HTML dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_index_redirects_to_login_when_unauthenticated(client):
|
||||
r = client.get("/", follow_redirects=False)
|
||||
assert r.status_code in (302, 307)
|
||||
assert r.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_scanner_redirects_to_login_when_unauthenticated(client):
|
||||
r = client.get("/scanner", follow_redirects=False)
|
||||
assert r.status_code in (302, 307)
|
||||
assert r.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_index_served_with_a_valid_session_cookie(client):
|
||||
client.post("/login", data={"username": "tester", "password": "s3cret-pass"})
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /login, /logout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_login_page_loads_without_auth(client):
|
||||
r = client.get("/login")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_login_with_correct_credentials_sets_cookie_and_redirects_home(client):
|
||||
r = client.post(
|
||||
"/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/"
|
||||
cookie = r.cookies.get(auth.SESSION_COOKIE_NAME)
|
||||
assert cookie
|
||||
assert auth._verify_session_token(cookie) == "tester"
|
||||
|
||||
|
||||
def test_login_with_wrong_password_redirects_back_with_error_and_no_cookie(client):
|
||||
r = client.post(
|
||||
"/login", data={"username": "tester", "password": "wrong"}, follow_redirects=False
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/login?error=1"
|
||||
assert auth.SESSION_COOKIE_NAME not in r.cookies
|
||||
|
||||
|
||||
def test_login_error_banner_renders_on_the_login_page(client):
|
||||
r = client.get("/login?error=1")
|
||||
assert r.status_code == 200
|
||||
assert "Invalid username or password" in r.text
|
||||
|
||||
|
||||
def test_logout_clears_the_session_cookie_and_redirects_to_login(client):
|
||||
client.post("/login", data={"username": "tester", "password": "s3cret-pass"})
|
||||
assert client.get("/").status_code == 200 # confirm we were logged in
|
||||
|
||||
r = client.get("/logout", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/login"
|
||||
|
||||
r2 = client.get("/", follow_redirects=False)
|
||||
assert r2.status_code in (302, 307) # session cookie was cleared
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Unit tests for mqtt_manager's per-node auth + TLS wiring
|
||||
(MQTT-PUBLIC-AUTH-PLAN.md dynsec cutover).
|
||||
|
||||
Pure client-construction tests — _build_client() only builds a paho Client
|
||||
object, it never calls .connect(), so no real broker is involved. What's
|
||||
verified here is the credential/TLS *selection logic*, matching what the
|
||||
server's dynsec plugin now expects (username=node_id, password=api_key,
|
||||
default-verified TLS on the public listener) — see
|
||||
Server/drb-c2-core/app/internal/dynsec.py and mosquitto.conf (read-only
|
||||
reference, not touched by this change).
|
||||
"""
|
||||
import ssl
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import credentials
|
||||
from app.internal.mqtt_manager import mqtt_manager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_mqtt_settings(monkeypatch):
|
||||
"""Every test gets known, isolated mqtt_* settings and a clean
|
||||
credentials._api_key so tests can't see real .env values or leak state
|
||||
between tests (mirrors the isolated_credentials fixture in test_auth.py)."""
|
||||
monkeypatch.setattr(settings, "mqtt_user", None)
|
||||
monkeypatch.setattr(settings, "mqtt_pass", None)
|
||||
monkeypatch.setattr(settings, "mqtt_tls", False)
|
||||
monkeypatch.setattr(credentials, "_api_key", None)
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential selection: api_key > legacy mqtt_user > anonymous
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_client_uses_node_id_and_api_key_when_present(monkeypatch):
|
||||
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
|
||||
|
||||
client = mqtt_manager._build_client()
|
||||
|
||||
assert client._username == settings.node_id.encode()
|
||||
assert client._password == b"the-api-key"
|
||||
|
||||
|
||||
def test_build_client_falls_back_to_legacy_mqtt_user_without_api_key(monkeypatch):
|
||||
monkeypatch.setattr(settings, "mqtt_user", "drb-node")
|
||||
monkeypatch.setattr(settings, "mqtt_pass", "legacy-pass")
|
||||
|
||||
client = mqtt_manager._build_client()
|
||||
|
||||
assert client._username == b"drb-node"
|
||||
assert client._password == b"legacy-pass"
|
||||
|
||||
|
||||
def test_build_client_api_key_takes_priority_over_legacy_mqtt_user(monkeypatch):
|
||||
"""Once a node has a real api_key, it must never fall back to the shared
|
||||
legacy login even if MQTT_USER/MQTT_PASS are still set in .env."""
|
||||
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
|
||||
monkeypatch.setattr(settings, "mqtt_user", "drb-node")
|
||||
monkeypatch.setattr(settings, "mqtt_pass", "legacy-pass")
|
||||
|
||||
client = mqtt_manager._build_client()
|
||||
|
||||
assert client._username == settings.node_id.encode()
|
||||
assert client._password == b"the-api-key"
|
||||
|
||||
|
||||
def test_build_client_with_no_credentials_connects_anonymously(monkeypatch):
|
||||
"""No api_key on disk, no legacy login configured: _build_client() must
|
||||
still return a usable client (paho, not this code, decides what happens
|
||||
on the wire — the dynsec broker refuses it, see the warning test below).
|
||||
This must never raise."""
|
||||
client = mqtt_manager._build_client()
|
||||
|
||||
assert client._username is None
|
||||
assert client._password is None
|
||||
|
||||
|
||||
def test_build_client_warns_when_no_credentials_available(caplog):
|
||||
with caplog.at_level("WARNING", logger="drb-edge-node"):
|
||||
mqtt_manager._build_client()
|
||||
|
||||
messages = [r.message for r in caplog.records]
|
||||
assert any("No API key" in m for m in messages), \
|
||||
"an unenrolled node must log a clear, greppable warning, not fail silently"
|
||||
|
||||
|
||||
def test_build_client_does_not_warn_when_api_key_present(monkeypatch, caplog):
|
||||
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
|
||||
|
||||
with caplog.at_level("WARNING", logger="drb-edge-node"):
|
||||
mqtt_manager._build_client()
|
||||
|
||||
assert not any("No API key" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_client_no_tls_by_default(monkeypatch):
|
||||
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
|
||||
monkeypatch.setattr(settings, "mqtt_tls", False)
|
||||
|
||||
client = mqtt_manager._build_client()
|
||||
|
||||
assert client._ssl_context is None
|
||||
|
||||
|
||||
def test_build_client_enables_tls_with_default_verification(monkeypatch):
|
||||
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
|
||||
monkeypatch.setattr(settings, "mqtt_tls", True)
|
||||
|
||||
client = mqtt_manager._build_client()
|
||||
|
||||
assert isinstance(client._ssl_context, ssl.SSLContext)
|
||||
# The whole point: default CA verification against the broker's real
|
||||
# Let's Encrypt cert must stay ON. tls_insecure_set(True) must never be
|
||||
# called — that would defeat verification entirely.
|
||||
assert client._ssl_context.verify_mode == ssl.CERT_REQUIRED
|
||||
assert client._tls_insecure is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Offline call buffer must be untouched by the auth/TLS change
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_client_does_not_touch_offline_buffer(monkeypatch):
|
||||
"""_build_client() is called fresh on every connect(); it must never
|
||||
reset or otherwise touch the offline call-buffer deque — that survives
|
||||
reconnects/auth changes by design (the whole point of the buffer)."""
|
||||
monkeypatch.setattr(credentials, "_api_key", "the-api-key")
|
||||
mqtt_manager._offline_buffer.append(("nodes/test/metadata", {"call_id": "sentinel"}))
|
||||
|
||||
with patch.object(mqtt_manager, "_offline_buffer", mqtt_manager._offline_buffer):
|
||||
mqtt_manager._build_client()
|
||||
|
||||
assert list(mqtt_manager._offline_buffer) == [("nodes/test/metadata", {"call_id": "sentinel"})]
|
||||
mqtt_manager._offline_buffer.clear()
|
||||
Reference in New Issue
Block a user