"""API tests: authentication, RBAC, client + ACL flows (spec section 64).""" from __future__ import annotations 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_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}")