🚧 WIP
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests package."""
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Shared pytest fixtures: sqlite-backed DB + FastAPI TestClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Ensure test settings before any app import resolves them.
|
||||
os.environ.setdefault("SESSION_SECRET", "test-session-secret-value-123456")
|
||||
os.environ.setdefault("SECRET_ENCRYPTION_KEY", "test-encryption-key-value-1234")
|
||||
os.environ.setdefault("ADMIN_INITIAL_PASSWORD", "test-admin-password-123")
|
||||
os.environ.setdefault("MOSQUITTO_DYNSEC_PASSWORD", "test-dynsec-password")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import Session, sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
from app import db as db_module # noqa: E402
|
||||
from app import main as main_module # noqa: E402
|
||||
from app.models import ManagementRole, ManagementUser # noqa: E402
|
||||
from app.security import hash_password # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session():
|
||||
"""Fresh in-memory database per test with schema + seeded users.
|
||||
|
||||
StaticPool + check_same_thread=False: the TestClient serves requests
|
||||
on a different thread than the fixture, but all access must share ONE
|
||||
sqlite connection (in-memory DBs are per-connection).
|
||||
"""
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
future=True,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
db_module.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, future=True)
|
||||
session = factory()
|
||||
# Seed one super admin and one auditor for RBAC tests.
|
||||
session.add_all(
|
||||
[
|
||||
ManagementUser(
|
||||
username="admin",
|
||||
password_hash=hash_password("admin-password-123"),
|
||||
role=ManagementRole.SUPER_ADMIN,
|
||||
must_change_password=False,
|
||||
),
|
||||
ManagementUser(
|
||||
username="auditor",
|
||||
password_hash=hash_password("auditor-password-123"),
|
||||
role=ManagementRole.AUDITOR,
|
||||
must_change_password=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
@pytest.fixture()
|
||||
def client(db_session, monkeypatch):
|
||||
"""TestClient wired to the fixture DB via dependency_overrides."""
|
||||
|
||||
def _override_get_db():
|
||||
# Yield the SAME session the test asserts against; the request
|
||||
# handler shares its transaction for simplicity in tests.
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
pass # session lifetime managed by the db_session fixture
|
||||
|
||||
main_module.app.dependency_overrides[db_module.get_db] = _override_get_db
|
||||
|
||||
# Skip the lifespan bootstrap (create_all + admin seed) against a
|
||||
# separate engine: tests manage the schema themselves.
|
||||
from app import main as _m
|
||||
|
||||
async def _noop_lifespan(app):
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(_m, "_bootstrap_admin", lambda: None)
|
||||
|
||||
# Fake dynsec so no broker is needed for API tests.
|
||||
class FakeDynsec:
|
||||
def __init__(self):
|
||||
self.clients: dict[str, dict] = {}
|
||||
self.roles: dict[str, dict] = {}
|
||||
self.groups: dict[str, dict] = {}
|
||||
|
||||
def _command(self, command, **kwargs):
|
||||
return {"success": True}
|
||||
|
||||
def create_client(self, username, password, **kwargs):
|
||||
self.clients[username] = {"username": username, "enabled": True, **kwargs}
|
||||
|
||||
def list_clients(self):
|
||||
return list(self.clients.values())
|
||||
|
||||
def delete_client(self, username):
|
||||
self.clients.pop(username, None)
|
||||
|
||||
def disable_client(self, username):
|
||||
self.clients[username]["enabled"] = False
|
||||
|
||||
def enable_client(self, username):
|
||||
self.clients[username]["enabled"] = True
|
||||
|
||||
def set_client_password(self, username, password):
|
||||
pass
|
||||
|
||||
def get_client(self, username):
|
||||
return self.clients[username]
|
||||
|
||||
def create_group(self, groupname, roles=None):
|
||||
self.groups[groupname] = {"groupname": groupname, "roles": roles or []}
|
||||
|
||||
def list_groups(self):
|
||||
return list(self.groups.values())
|
||||
|
||||
def delete_group(self, groupname):
|
||||
self.groups.pop(groupname, None)
|
||||
|
||||
def create_role(self, rolename, acls=None):
|
||||
self.roles[rolename] = {"rolename": rolename, "acls": acls or []}
|
||||
|
||||
def list_roles(self):
|
||||
return list(self.roles.values())
|
||||
|
||||
def delete_role(self, rolename):
|
||||
self.roles.pop(rolename, None)
|
||||
|
||||
def set_role_acls(self, rolename, acls):
|
||||
self.roles[rolename]["acls"] = acls
|
||||
|
||||
def get_role(self, rolename):
|
||||
return self.roles[rolename]
|
||||
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
fake = FakeDynsec()
|
||||
monkeypatch.setattr(main_module, "dynsec_client", fake)
|
||||
|
||||
from app.routers import mqtt as mqtt_router
|
||||
|
||||
monkeypatch.setattr(mqtt_router, "dynsec_client", fake)
|
||||
|
||||
with TestClient(main_module.app) as test_client:
|
||||
yield test_client
|
||||
|
||||
main_module.app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def login(client: TestClient, username: str, password: str):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login", json={"username": username, "password": password}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_client(client):
|
||||
login(client, "admin", "admin-password-123")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def auditor_client(client):
|
||||
login(client, "auditor", "auditor-password-123")
|
||||
return client
|
||||
@@ -0,0 +1,130 @@
|
||||
"""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_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_listener_management_and_preview(admin_client):
|
||||
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 false" in config_text
|
||||
|
||||
delete = admin_client.delete(f"/api/v1/broker/listeners/{listener_id}")
|
||||
assert delete.status_code == 204
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Config generator unit tests (spec section 64: configuration generation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.configgen import generate_config
|
||||
from app.models import BrokerSettings, ListenerConfig
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(db_session):
|
||||
yield db_session
|
||||
|
||||
|
||||
def test_generate_with_one_listener(session):
|
||||
session.add(
|
||||
ListenerConfig(
|
||||
name="Production MQTT",
|
||||
port=1883,
|
||||
protocol="mqtt",
|
||||
allow_anonymous=False,
|
||||
max_connections=-1,
|
||||
tls_enabled=False,
|
||||
require_certificate=False,
|
||||
use_identity_as_username=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert "listener 1883" in result.config_text
|
||||
assert "per_listener_settings false" in result.config_text
|
||||
assert "global_plugin /usr/lib/mosquitto_dynamic_security.so" in result.config_text
|
||||
assert "allow_anonymous false" in result.config_text
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
def test_generate_includes_global_settings(session):
|
||||
session.add(ListenerConfig(name="l", port=1883, protocol="mqtt"))
|
||||
session.add(BrokerSettings(key="max_packet_size", value="2000000", value_type="int"))
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert "max_packet_size 2000000" in result.config_text
|
||||
|
||||
|
||||
def test_generate_refuses_without_listeners(session):
|
||||
with pytest.raises(ValueError, match="no enabled listeners"):
|
||||
generate_config(session)
|
||||
|
||||
|
||||
def test_disabled_listeners_are_omitted(session):
|
||||
session.add(ListenerConfig(name="off", port=1884, protocol="mqtt", enabled=False))
|
||||
session.add(ListenerConfig(name="on", port=1883, protocol="mqtt"))
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert "listener 1883" in result.config_text
|
||||
assert "listener 1884" not in result.config_text
|
||||
|
||||
|
||||
def test_tls_listener_without_pki_warns(session):
|
||||
session.add(
|
||||
ListenerConfig(
|
||||
name="tls-later",
|
||||
port=8883,
|
||||
protocol="mqtt",
|
||||
tls_enabled=True,
|
||||
tls_min_version="tlsv1.2",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert any("PKI is not available" in w for w in result.warnings)
|
||||
assert "tls_version tlsv1.2" in result.config_text
|
||||
Reference in New Issue
Block a user