382 lines
14 KiB
Python
382 lines
14 KiB
Python
"""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_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) |