🚧 Add topic explorer

This commit is contained in:
Jonas Bengtsson
2026-09-20 09:23:04 +02:00
parent 1e491642f1
commit 6385a27411
11 changed files with 1184 additions and 6 deletions
+65
View File
@@ -245,6 +245,71 @@ def client(db_session, monkeypatch):
monkeypatch.setattr(broker_router, "_call_control", _fake_call_control)
monkeypatch.setattr(system_router, "_call_control", _fake_call_control)
# The topic explorer talks to the broker via MQTT. Fake the session
# registry + publish/retained so the endpoints can be exercised
# without a live broker.
from app.routers import topics as topics_router
from app import explorer as explorer_module
class FakeSession:
def __init__(self, id, username, topic_filter):
self.id = id
self.username = username
self.topic_filter = topic_filter
class FakeSessions:
def __init__(self):
self._sessions = {}
self._counter = 0
def sweep(self):
pass
def start(self, username, topic_filter):
self._counter += 1
sid = f"fake-{self._counter}"
self._sessions[sid] = FakeSession(sid, username, topic_filter)
return self._sessions[sid]
def get(self, session_id):
if session_id not in self._sessions:
raise KeyError(session_id)
return self._sessions[session_id]
def stop(self, session_id):
self._sessions.pop(session_id, None)
def stop_all(self):
self._sessions.clear()
fake_sessions = FakeSessions()
monkeypatch.setattr(explorer_module, "sessions", fake_sessions)
def fake_messages_since(session_id, index):
return []
def fake_list_retained(max_results=200, timeout=3.0):
return [
{
"index": 0,
"topic": "site/config/level",
"qos": 1,
"retain": True,
"received_at": 12345.0,
"payload_b64": "MTIz",
"payload_text": "123",
"properties": None,
}
]
def fake_publish(topic, payload, qos, retain, timeout=10.0):
fake_publish.calls.append((topic, payload, qos, retain))
fake_publish.calls = []
monkeypatch.setattr(explorer_module, "messages_since", fake_messages_since)
monkeypatch.setattr(explorer_module, "list_retained", fake_list_retained)
monkeypatch.setattr(explorer_module, "publish_message", fake_publish)
# The router imports names from the module: patch there too.
monkeypatch.setattr(topics_router.explorer, "sessions", fake_sessions)
monkeypatch.setattr(topics_router.explorer, "messages_since", fake_messages_since)
monkeypatch.setattr(topics_router.explorer, "list_retained", fake_list_retained)
monkeypatch.setattr(topics_router.explorer, "publish_message", fake_publish)
with TestClient(main_module.app) as test_client:
yield test_client
+109
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import json
from tests.conftest import login
@@ -239,6 +241,113 @@ def test_broker_status_endpoint(admin_client, auditor_client):
metrics.reset()
# --- Topic explorer (spec sections 35, 36) ------------------------------------
def test_topic_explorer_rbac(auditor_client):
"""Subscribe/stream/retained need OPERATOR+; publish/clear need
BROKER_ADMIN (spec section 35: access restricted through
management RBAC)."""
# Auditor may not even subscribe (read-only role is not OPERATOR).
assert (
auditor_client.post("/api/v1/topics/subscribe", json={"topic_filter": "#"}).status_code
== 403
)
assert (
auditor_client.get("/api/v1/topics/retained").status_code == 403
)
assert (
auditor_client.post(
"/api/v1/topics/publish", json={"topic": "t", "payload_b64": ""}
).status_code
== 403
)
def test_topic_explorer_flow(admin_client):
"""Operator-level (admin is superuser) subscribe/stream/retained."""
sub = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "#"})
assert sub.status_code == 200
session_id = sub.json()["session_id"]
# The stream endpoint exists and is SSE. NOTE: consuming the stream
# itself would hang (infinite generator by design) — verify the
# session's liveness through the subscribe/404 contract instead:
# an unknown session must 404, a live one was accepted above.
missing = admin_client.get("/api/v1/topics/stream/does-not-exist")
assert missing.status_code == 404
# Retained listing works.
retained = admin_client.get("/api/v1/topics/retained")
assert retained.status_code == 200
assert any(m["topic"] == "site/config/level" for m in retained.json())
def test_topic_explorer_publish_audited(admin_client, db_session):
"""Publishes through the test console are audited (spec section 35),
and wildcards are rejected."""
import base64
from sqlalchemy import select
from app.models import AuditEvent
payload = {"cmd": "restart"}
response = admin_client.post(
"/api/v1/topics/publish",
json={
"topic": "site/test/command",
"payload_b64": base64.b64encode(json.dumps(payload).encode()).decode(),
"qos": 1,
"retain": False,
},
)
assert response.status_code == 204
events = db_session.scalars(
select(AuditEvent).where(AuditEvent.action == "TOPIC_PUBLISHED")
).all()
assert any(e.object_id == "site/test/command" for e in events)
# The audit row records metadata, never the payload content.
for e in events:
if e.object_id == "site/test/command":
assert e.new_value["qos"] == 1
assert "payload" not in json.dumps(e.new_value).lower()
# Wildcards cannot be published to.
wildcard = admin_client.post(
"/api/v1/topics/publish",
json={"topic": "site/+/command", "payload_b64": "", "qos": 0},
)
assert wildcard.status_code == 400
# A mis-placed # is rejected on subscribe too.
bad = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "#/deep"})
assert bad.status_code == 400
def test_topic_explorer_clear_retained(admin_client, db_session):
"""Clearing a retained message publishes a zero-length retained
payload and audits RETAINED_CLEARED (spec section 36)."""
from sqlalchemy import select
from app.models import AuditEvent
cleared = admin_client.post(
"/api/v1/topics/retained/clear", json={"topic": "site/config/level"}
)
assert cleared.status_code == 204
events = db_session.scalars(
select(AuditEvent).where(AuditEvent.action == "RETAINED_CLEARED")
).all()
assert any(e.object_id == "site/config/level" for e in events)
def test_topic_explorer_stream_404_for_unknown_session(admin_client):
missing = admin_client.get("/api/v1/topics/stream/does-not-exist")
assert missing.status_code == 404
def test_role_acl_lifecycle(admin_client):
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})