Files
mqtt-broker-stack/backend/tests/test_api.py
T
2026-09-20 17:57:05 +02:00

824 lines
30 KiB
Python

"""API tests: authentication, RBAC, client + ACL flows (spec section 64)."""
from __future__ import annotations
import json
from tests.conftest import login
def test_health_live(client):
response = client.get("/health/live")
assert response.status_code == 200
def test_login_sets_cookie_and_session(client):
response = login(client, "admin", "admin-password-123")
assert response.json()["role"] == "super_admin"
session = client.get("/api/v1/auth/session")
assert session.status_code == 200
assert session.json()["username"] == "admin"
def test_login_wrong_password_audited(client):
response = client.post(
"/api/v1/auth/login", json={"username": "admin", "password": "wrong"}
)
assert response.status_code == 401
def test_unauthenticated_api_rejected(client):
response = client.get("/api/v1/mqtt/clients")
assert response.status_code == 401
def test_auditor_cannot_create_clients(auditor_client):
response = auditor_client.post(
"/api/v1/mqtt/clients", json={"username": "ps042"}
)
assert response.status_code == 403
def test_create_client_returns_password_once(admin_client):
response = admin_client.post("/api/v1/mqtt/clients", json={"username": "ps042"})
assert response.status_code == 201
body = response.json()
assert body["username"] == "ps042"
assert len(body["password"]) >= 20 # random password generated
listing = admin_client.get("/api/v1/mqtt/clients")
assert any(c["username"] == "ps042" for c in listing.json())
def test_disable_enable_delete_client(admin_client):
admin_client.post("/api/v1/mqtt/clients", json={"username": "sensor-1"})
response = admin_client.post("/api/v1/mqtt/clients/sensor-1/disable")
assert response.status_code == 204
clients = {c["username"]: c for c in admin_client.get("/api/v1/mqtt/clients").json()}
assert clients["sensor-1"]["enabled"] is False
assert admin_client.post("/api/v1/mqtt/clients/sensor-1/enable").status_code == 204
response = admin_client.delete("/api/v1/mqtt/clients/sensor-1")
assert response.status_code == 204
def test_management_identity_is_protected(admin_client):
"""The dynsec admin (MOSQUITTO_DYNSEC_USERNAME) is the backend's own
service account: hidden from the listing and immune to password reset,
disable and delete. A reset previously severed the management channel
(Mosquitto kicks all connections for the username) — hence the guard.
"""
# Create "admin" directly so the filter/guard has something to catch.
# (The FakeDynsec fixture is empty at test start.)
admin_client.post("/api/v1/mqtt/clients", json={"username": "admin"})
# Hidden from the listing even though dynsec knows the client.
listing = admin_client.get("/api/v1/mqtt/clients").json()
assert all(c["username"] != "admin" for c in listing)
assert admin_client.get("/api/v1/mqtt/clients/admin").status_code == 404
# Every mutation targeting it returns 400, not 2xx.
assert (
admin_client.post("/api/v1/mqtt/clients/admin/password:reset").status_code
== 400
)
assert admin_client.post("/api/v1/mqtt/clients/admin/disable").status_code == 400
assert admin_client.delete("/api/v1/mqtt/clients/admin").status_code == 400
# Creating a client that shadows the identity is refused as well.
assert (
admin_client.post("/api/v1/mqtt/clients", json={"username": "admin"}).status_code
== 400
)
def test_password_reset_still_works_for_normal_clients(admin_client):
admin_client.post("/api/v1/mqtt/clients", json={"username": "ps042"})
reset = admin_client.post("/api/v1/mqtt/clients/ps042/password:reset")
assert reset.status_code == 200
body = reset.json()
assert body["username"] == "ps042"
assert len(body["password"]) >= 20 # random password, shown once
def test_client_id_binding(admin_client):
"""Bind/clear the expected client ID (spec section 11) and refuse
it for the management identity."""
admin_client.post("/api/v1/mqtt/clients", json={"username": "device-1"})
bound = admin_client.put(
"/api/v1/mqtt/clients/device-1/clientid",
json={"clientid": "device-ps042"},
)
assert bound.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/device-1").json()
assert client["clientid"] == "device-ps042"
# Clearing with an empty string unbinds (any client id may connect).
cleared = admin_client.put(
"/api/v1/mqtt/clients/device-1/clientid", json={"clientid": ""}
)
assert cleared.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/device-1").json()
assert client["clientid"] in (None, "")
# The management identity is never manageable (existing guard).
refused = admin_client.put(
"/api/v1/mqtt/clients/admin/clientid", json={"clientid": "x"}
)
assert refused.status_code == 400
# --- System: broker restart + restart-required flag (spec 28, 29) -------------
def test_deploy_sets_restart_required_on_listener_change(admin_client):
"""Adding a listener definition vs the bootstrap config must flag a
pending restart (reload cannot add listeners, spec section 28)."""
# No deployed revision yet -> compared against the bootstrap config
# (listener 1883 0.0.0.0). A listener on 8883 differs from that.
admin_client.post(
"/api/v1/broker/listeners",
json={"name": "New TLS", "port": 8883, "protocol": "mqtt"},
)
deploy = admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "listener added"}
)
assert deploy.status_code == 200
body = deploy.json()
assert body["restart_required"] is True
# The status endpoint reflects the same flag (drives the glow).
status = admin_client.get("/api/v1/system/status").json()
assert status["restart_required"] is True
assert status["broker_alive"] is True
def test_deploy_without_listener_change_needs_no_restart(admin_client):
"""Deploying a config whose listener lines match the previous deployed
revision must NOT set the flag (everything else is reload-safe)."""
admin_client.post(
"/api/v1/broker/listeners",
json={"name": "Baseline", "port": 1883, "protocol": "mqtt"},
)
first = admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "baseline"}
).json()
# 1883 matches the bootstrap signature on the FIRST deploy; the flag
# may or may not be set then. The SECOND deploy (identical listener
# lines) must clear it.
second = admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "no change"}
).json()
assert second["restart_required"] is False
def test_restart_clears_the_flag(admin_client):
admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "flag set"}
)
restarted = admin_client.post("/api/v1/system/broker/restart")
assert restarted.status_code == 200
assert restarted.json()["status"] == "restarting"
status = admin_client.get("/api/v1/system/status").json()
assert status["restart_required"] is False
def test_system_endpoints_rbac(auditor_client):
"""Status is read-only (auditor may read); restart is privileged."""
assert auditor_client.get("/api/v1/system/status").status_code == 200
assert (
auditor_client.post("/api/v1/system/broker/restart").status_code == 403
)
def test_broker_status_endpoint(admin_client, auditor_client):
"""GET /broker/status returns the $SYS snapshot (spec 33/55).
No $SYS messages are ingested in tests (no real broker), so the
endpoint must report available: False with all metrics None; after
ingesting fake $SYS messages the snapshot reflects them. Read-only:
the auditor role may view (spec section 9).
"""
from app import metrics
metrics.reset()
empty = admin_client.get("/api/v1/broker/status")
assert empty.status_code == 200
body = empty.json()
assert body["available"] is False
assert body["version"] is None
assert body["clients_connected"] is None
# Ingest fake $SYS telemetry and verify the snapshot reflects it.
metrics.handle_sys_message("$SYS/broker/version", b"2.1.2")
metrics.handle_sys_message("$SYS/broker/clients/connected", b"4")
metrics.handle_sys_message("$SYS/broker/retained messages/count", b"7")
metrics.handle_sys_message("$SYS/broker/load/messages/received/1min", b"1.25")
body = admin_client.get("/api/v1/broker/status").json()
assert body["available"] is True
assert body["version"] == "2.1.2"
assert body["clients_connected"] == 4
assert body["retained_count"] == 7 # topic contains a literal space
assert body["load_messages_received_1min"] == 1.25
assert body["last_update"] is not None
# Unknown $SYS topics are ignored without breaking the snapshot.
metrics.handle_sys_message("$SYS/broker/something/unknown", b"x")
body = admin_client.get("/api/v1/broker/status").json()
assert body["available"] is True
# Read-only for every management role (spec section 9: auditor has
# read access to configuration/status).
assert auditor_client.get("/api/v1/broker/status").status_code == 200
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_stream_delivers_first_message(admin_client, monkeypatch):
"""Regression test: the SSE cursor must include a session's FIRST
buffered message (index 0). messages_since is strictly-greater, so
a cursor starting at 0 dropped it — subscribe, publish {"cmd":"start"}
then {"cmd":"stop"}, and the stream must deliver BOTH."""
from app import explorer as explorer_module
sub = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "e2e/#"})
assert sub.status_code == 200
session_id = sub.json()["session_id"]
session = explorer_module.sessions.get(session_id)
# Simulate the broker delivering both payloads (as _on_message would).
session.buffer = [
{
"index": 0,
"topic": "e2e/diff/device1",
"qos": 1,
"retain": False,
"received_at": 1.0,
"payload_b64": "eyJjbWQiOiAic3RhcnQifQ==",
"payload_text": '{"cmd": "start"}',
"properties": None,
},
{
"index": 1,
"topic": "e2e/diff/device1",
"qos": 1,
"retain": False,
"received_at": 2.0,
"payload_b64": "eyJjbWQiOiAic3RvcCJ9",
"payload_text": '{"cmd": "stop"}',
"properties": None,
},
]
with admin_client.stream("GET", f"/api/v1/topics/stream/{session_id}") as response:
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
received = []
for line in response.iter_lines():
if line.startswith("data:"):
received.append(json.loads(line[len("data:"):].strip()))
if len(received) == 2:
break
assert [m["payload_text"] for m in received] == [
'{"cmd": "start"}',
'{"cmd": "stop"}',
], "stream must deliver the session's first message (index 0), not drop it"
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"})
acls = [
{
"acltype": "publishClientSend",
"topic": "site/%u/telemetry/#",
"allow": True,
"priority": 0,
},
{
"acltype": "subscribePattern",
"topic": "site/%u/config/#",
"allow": True,
"priority": 1,
},
]
response = admin_client.put("/api/v1/mqtt/roles/telemetry", json={"acls": acls})
assert response.status_code == 200
roles = {r["rolename"]: r for r in admin_client.get("/api/v1/mqtt/roles").json()}
assert roles["telemetry"]["acls"] == acls
def test_group_lifecycle(admin_client):
response = admin_client.post(
"/api/v1/mqtt/groups", json={"groupname": "sensors"}
)
assert response.status_code == 201
groups = {g["groupname"]: g for g in admin_client.get("/api/v1/mqtt/groups").json()}
assert "sensors" in groups
assert admin_client.delete("/api/v1/mqtt/groups/sensors").status_code == 204
def test_client_role_assignment(admin_client):
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
admin_client.post("/api/v1/mqtt/clients", json={"username": "assign-test"})
response = admin_client.post(
"/api/v1/mqtt/clients/assign-test/roles",
json={"rolename": "telemetry", "priority": 5},
)
assert response.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/assign-test").json()
roles = {r["rolename"] for r in client["roles"]}
assert "telemetry" in roles
assert (
admin_client.delete("/api/v1/mqtt/clients/assign-test/roles/telemetry").status_code
== 204
)
client = admin_client.get("/api/v1/mqtt/clients/assign-test").json()
assert all(r["rolename"] != "telemetry" for r in client["roles"])
def test_group_member_assignment(admin_client):
admin_client.post("/api/v1/mqtt/groups", json={"groupname": "sensors"})
admin_client.post("/api/v1/mqtt/clients", json={"username": "member-test"})
response = admin_client.post(
"/api/v1/mqtt/groups/sensors/clients",
json={"username": "member-test", "priority": 1},
)
assert response.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/member-test").json()
groups = {g["groupname"] for g in client["groups"]}
assert "sensors" in groups
assert (
admin_client.delete("/api/v1/mqtt/groups/sensors/clients/member-test").status_code
== 204
)
client = admin_client.get("/api/v1/mqtt/clients/member-test").json()
assert all(g["groupname"] != "sensors" for g in client["groups"])
def test_group_role_assignment(admin_client):
admin_client.post("/api/v1/mqtt/groups", json={"groupname": "sensors"})
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
response = admin_client.post(
"/api/v1/mqtt/groups/sensors/roles",
json={"rolename": "telemetry", "priority": 2},
)
assert response.status_code == 204
assert (
admin_client.delete("/api/v1/mqtt/groups/sensors/roles/telemetry").status_code
== 204
)
def test_role_deletion(admin_client):
"""deleteRole removes the role everywhere (spec section 13): the broker's
dynsec deleteRole strips it from every client and group that holds it."""
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "obsolete"})
admin_client.post("/api/v1/mqtt/clients", json={"username": "ps042"})
admin_client.post("/api/v1/mqtt/clients/ps042/roles", json={"rolename": "obsolete"})
admin_client.post("/api/v1/mqtt/groups", json={"groupname": "legacy"})
admin_client.post("/api/v1/mqtt/groups/legacy/roles", json={"rolename": "obsolete"})
deleted = admin_client.delete("/api/v1/mqtt/roles/obsolete")
assert deleted.status_code == 204
# Role gone from the listing...
roles = {r["rolename"] for r in admin_client.get("/api/v1/mqtt/roles").json()}
assert "obsolete" not in roles
# ...and the broker's cleanup means clients/groups no longer hold it.
client = admin_client.get("/api/v1/mqtt/clients/ps042").json()
assert all(r["rolename"] != "obsolete" for r in client["roles"])
groups = admin_client.get("/api/v1/mqtt/groups").json()
legacy = next(g for g in groups if g["groupname"] == "legacy")
assert all(r["rolename"] != "obsolete" for r in legacy["roles"])
response = admin_client.post(
"/api/v1/broker/listeners",
json={"name": "Production MQTT", "port": 1883, "protocol": "mqtt"},
)
assert response.status_code == 201
listener_id = response.json()["id"]
preview = admin_client.get("/api/v1/broker/config/preview")
assert preview.status_code == 200
config_text = preview.json()["config_text"]
assert "listener 1883" in config_text
# Generated config must not use options deprecated in 2.1 (spec 3.1).
assert "password_file" not in config_text
assert "acl_file" not in config_text
assert "per_listener_settings" not in config_text
delete = admin_client.delete(f"/api/v1/broker/listeners/{listener_id}")
assert delete.status_code == 204
def test_listener_update_and_preview(admin_client):
listener_id = admin_client.post(
"/api/v1/broker/listeners",
json={"name": "Production MQTT", "port": 1883, "protocol": "mqtt"},
).json()["id"]
updated = admin_client.put(
f"/api/v1/broker/listeners/{listener_id}",
json={
"name": "Production MQTT",
"port": 1884,
"bind_address": "127.0.0.1",
"protocol": "websockets",
"allow_anonymous": True,
},
)
assert updated.status_code == 200, updated.text
body = updated.json()
assert body["port"] == 1884
assert body["bind_address"] == "127.0.0.1"
assert body["protocol"] == "websockets"
assert body["allow_anonymous"] is True
config_text = admin_client.get("/api/v1/broker/config/preview").json()[
"config_text"
]
assert "listener 1884 127.0.0.1" in config_text
assert "protocol websockets" in config_text
assert "listener_allow_anonymous true" in config_text
def test_audit_events_recorded(admin_client, db_session):
from sqlalchemy import select
from app.models import AuditEvent
admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"})
actions = db_session.scalars(select(AuditEvent.action)).all()
assert "CLIENT_CREATED" in actions
# --- PKI: CA + server certificates (spec sections 16-19) -----------------------
def test_ca_creation_and_inventory(admin_client):
response = admin_client.post(
"/api/v1/pki/ca",
json={
"common_name": "MQTT Platform Root CA",
"validity_days": 3650,
"key_algorithm": "ecdsa-p256",
},
)
assert response.status_code == 201, response.text
ca = response.json()
assert ca["is_ca"] is True
assert ca["status"] == "issued"
assert len(ca["serial_number"]) > 10
assert ca["fingerprint"].count(":") == 31 # sha-256 hex pairs
# No private key material ever appears in a response.
assert "private_key_pem" not in ca
assert "BEGIN PRIVATE KEY" not in response.text
cas = admin_client.get("/api/v1/pki/ca").json()
assert any(c["common_name"] == "MQTT Platform Root CA" for c in cas)
def test_server_certificate_issuance(admin_client):
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={
"common_name": "Test CA",
"validity_days": 3650,
"key_algorithm": "ecdsa-p256",
},
).json()["id"]
response = admin_client.post(
"/api/v1/pki/certificates",
json={
"ca_id": ca_id,
"common_name": "mqtt.example.local",
"validity_days": 365,
"dns_sans": ["mqtt", "mqtt.local"],
"ip_sans": ["10.20.30.40"],
},
)
assert response.status_code == 201, response.text
cert = response.json()
assert cert["is_ca"] is False
assert cert["parent_id"] == ca_id
assert cert["dns_sans"] == ["mqtt", "mqtt.local"]
assert cert["ip_sans"] == ["10.20.30.40"]
assert "BEGIN PRIVATE KEY" not in response.text
# The download endpoint returns the public PEM.
download = admin_client.get(f"/api/v1/pki/certificates/{cert['id']}/download")
assert download.status_code == 200
assert "BEGIN CERTIFICATE" in download.text
assert "PRIVATE KEY" not in download.text
def test_server_certificate_is_cryptographically_valid(admin_client):
"""The issued chain must verify against the issuing CA (real crypto)."""
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={
"common_name": "Verify CA",
"validity_days": 3650,
"key_algorithm": "ecdsa-p256",
},
).json()["id"]
cert_id = admin_client.post(
"/api/v1/pki/certificates",
json={"ca_id": ca_id, "common_name": "broker.local", "validity_days": 30},
).json()["id"]
ca_pem = admin_client.get(f"/api/v1/pki/certificates/{ca_id}/download").text
cert_pem = admin_client.get(f"/api/v1/pki/certificates/{cert_id}/download").text
ca_cert = x509.load_pem_x509_certificate(ca_pem.encode())
cert = x509.load_pem_x509_certificate(cert_pem.encode())
# Signature verification: the leaf must validate against the CA key.
ca_cert.public_key().verify(
cert.signature,
cert.tbs_certificate_bytes,
ec.ECDSA(cert.signature_hash_algorithm),
)
# Serial and fingerprint match the inventory fields.
inv = next(
c
for c in admin_client.get("/api/v1/pki/certificates").json()
if c["id"] == cert_id
)
assert int(inv["serial_number"]) == cert.serial_number
from app.pki import fingerprint as pki_fingerprint
assert inv["fingerprint"] == pki_fingerprint(cert_pem)
# --- Client certificates (spec sections 20, 24) ---------------------------------
def test_client_certificate_issuance_and_binding(admin_client, db_session):
"""Issue-client-cert requires an existing dynsec client; the bundle
carries cert+key+CA exactly once and binds the identity link."""
from sqlalchemy import select
from app.models import MqttClientMetadata
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={
"common_name": "Client CA",
"validity_days": 3650,
"key_algorithm": "ecdsa-p256",
},
).json()["id"]
# Without the dynsec client it must 404, not issue a useless cert.
missing = admin_client.post(
"/api/v1/pki/certificates/client",
json={"username": "ghost-device", "ca_id": ca_id, "validity_days": 365},
)
assert missing.status_code == 404
admin_client.post("/api/v1/mqtt/clients", json={"username": "ps042"})
response = admin_client.post(
"/api/v1/pki/certificates/client",
json={"username": "ps042", "ca_id": ca_id, "validity_days": 365},
)
assert response.status_code == 201, response.text
bundle = response.json()
assert bundle["username"] == "ps042"
assert "BEGIN CERTIFICATE" in bundle["certificate_pem"]
assert "BEGIN PRIVATE KEY" in bundle["private_key_pem"]
assert "BEGIN CERTIFICATE" in bundle["ca_chain_pem"]
# Identity binding recorded in metadata (spec section 24).
meta = db_session.get(MqttClientMetadata, "ps042")
assert meta is not None
assert str(meta.certificate_id) == bundle["certificate_id"]
# Inventory shows the client cert with cert_type=client. The bundle
# legitimately carries the private key ONCE (spec section 20); the
# INVENTORY must never leak it.
inventory = admin_client.get("/api/v1/pki/certificates")
certs = inventory.json()
entry = next(c for c in certs if c["id"] == bundle["certificate_id"])
assert entry["cert_type"] == "client"
assert entry["common_name"] == "ps042"
# The bound MQTT identity is exposed for differentiation in the UI
# (client certs list the device they authenticate, spec section 24).
assert entry["username"] == "ps042"
assert "PRIVATE KEY" not in inventory.text
# Server certs carry no username.
server_entries = [c for c in certs if c.get("cert_type") == "server"]
assert all(c["username"] is None for c in server_entries)
def test_client_cert_delete_unlinks_metadata(admin_client, db_session):
from app.models import MqttClientMetadata
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={"common_name": "Del CA", "validity_days": 3650, "key_algorithm": "ecdsa-p256"},
).json()["id"]
admin_client.post("/api/v1/mqtt/clients", json={"username": "unlink-me"})
bundle = admin_client.post(
"/api/v1/pki/certificates/client",
json={"username": "unlink-me", "ca_id": ca_id, "validity_days": 365},
).json()
deleted = admin_client.delete(
f"/api/v1/pki/certificates/{bundle['certificate_id']}"
)
assert deleted.status_code == 204
# The metadata row survives, unlinked (FK behaviour, mirrored in API).
db_session.expire_all()
meta = db_session.get(MqttClientMetadata, "unlink-me")
assert meta is not None
assert meta.certificate_id is None
def test_listener_cert_binding_roundtrip(admin_client):
"""Listener cert_id must round-trip through create/update (the gap that
previously made TLS unusable from the API)."""
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={"common_name": "Listener CA", "validity_days": 3650, "key_algorithm": "ecdsa-p256"},
).json()["id"]
cert_id = admin_client.post(
"/api/v1/pki/certificates",
json={"ca_id": ca_id, "common_name": "broker.local", "validity_days": 365},
).json()["id"]
created = admin_client.post(
"/api/v1/broker/listeners",
json={
"name": "TLS listener",
"port": 8883,
"protocol": "mqtt",
"tls_enabled": True,
"tls_min_version": "tlsv1.2",
"require_certificate": True,
"use_identity_as_username": True,
"cert_id": cert_id,
},
)
assert created.status_code == 201, created.text
listener = created.json()
assert listener["cert_id"] == cert_id
assert listener["require_certificate"] is True
# The generated config now emits the real certfile/keyfile/cafile.
preview = admin_client.get("/api/v1/broker/config/preview").json()
assert f"certfile /mosquitto/certs/{cert_id}.crt" in preview["config_text"]
assert f"keyfile /mosquitto/certs/{cert_id}.key" in preview["config_text"]
assert f"cafile /mosquitto/certs/{ca_id}.crt" in preview["config_text"]
assert "require_certificate true" in preview["config_text"]
assert "use_identity_as_username true" in preview["config_text"]
# Update keeps the binding (full-object PUT).
updated = admin_client.put(
f"/api/v1/broker/listeners/{listener['id']}",
json={
"name": "TLS listener",
"port": 8883,
"protocol": "mqtt",
"tls_enabled": True,
"tls_min_version": "tlsv1.2",
"require_certificate": True,
"use_identity_as_username": True,
"cert_id": cert_id,
},
)
assert updated.status_code == 200
assert updated.json()["cert_id"] == cert_id
admin_client.delete(f"/api/v1/broker/listeners/{listener['id']}")
admin_client.delete(f"/api/v1/pki/certificates/{cert_id}")
admin_client.delete(f"/api/v1/pki/certificates/{ca_id}")