🚧 Add topic explorer
This commit is contained in:
@@ -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"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user