"""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: """Mirrors the corrected wire contract: _command returns the `data` object; client objects use `disabled` (absent == enabled).""" def __init__(self): self.clients: dict[str, dict] = {} self.roles: dict[str, dict] = {} self.groups: dict[str, dict] = {} def _command(self, command, **kwargs): return {} def create_client(self, username, password, **kwargs): self.clients[username] = {"username": username, "disabled": False, **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]["disabled"] = True def enable_client(self, username): self.clients[username]["disabled"] = False def set_client_password(self, username, password): pass def set_client_id(self, username, clientid=None): # Mirrors the real broker's setClientId: empty string unbinds. client = self.clients.get(username) if client is None: from app.dynsec import DynsecError raise DynsecError(f"Client not found: {username}") client["clientid"] = clientid or "" def get_client(self, username): # Mirrors the real broker: getClient on a missing client # returns a dynsec error, which the router maps to 404. if username not in self.clients: from app.dynsec import DynsecError raise DynsecError(f"Client not found: {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): # Mirrors the real broker (roles.c role__remove_all_clients / # role__remove_all_groups): deleteRole strips the role from # every client and group that holds it. self.roles.pop(rolename, None) for client in self.clients.values(): client["roles"] = [ r for r in client.get("roles", []) if r["rolename"] != rolename ] for group in self.groups.values(): group["roles"] = [ r for r in group.get("roles", []) if r["rolename"] != rolename ] def set_role_acls(self, rolename, acls): self.roles[rolename]["acls"] = acls def get_role(self, rolename): return self.roles[rolename] def add_client_role(self, username, rolename, priority=-1): client = self.clients.setdefault(username, {"username": username, "disabled": False}) client["roles"] = [ *client.get("roles", []), {"rolename": rolename, "priority": priority}, ] def remove_client_role(self, username, rolename): client = self.clients.get(username, {}) client["roles"] = [ r for r in client.get("roles", []) if r["rolename"] != rolename ] def add_group_client(self, groupname, username, priority=1): self.clients.setdefault(username, {}).setdefault("groups", []).append( {"groupname": groupname, "priority": priority} ) def remove_group_client(self, groupname, username): client = self.clients.get(username, {}) client["groups"] = [ g for g in client.get("groups", []) if g["groupname"] != groupname ] def add_group_role(self, groupname, rolename, priority=-1): group = self.groups.setdefault(groupname, {"groupname": groupname, "roles": []}) group["roles"] = [ *group.get("roles", []), {"rolename": rolename, "priority": priority}, ] def remove_group_role(self, groupname, rolename): group = self.groups.get(groupname, {}) group["roles"] = [ r for r in group.get("roles", []) if r["rolename"] != 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) from app.routers import pki as pki_router # The PKI client-cert endpoint validates the dynsec client exists. monkeypatch.setattr(pki_router, "dynsec_client", fake) # The control sidecar is not running in tests: fake its responses so # deploy/system endpoints can be exercised (broker_alive always true). from app.routers import broker as broker_router from app.routers import system as system_router def _fake_call_control(path, *, method="POST", json_body=None): if path == "/deploy": return { "status": "deployed", "broker_healthy": True, "validation": {"valid": True}, } if path == "/restart": return {"status": "restarting"} if path == "/status": return {"broker_pid_alive": True, "config_file": "/mosquitto/config/mosquitto.conf"} raise AssertionError(f"unexpected control path in tests: {path}") monkeypatch.setattr(broker_router, "_call_control", _fake_call_control) monkeypatch.setattr(system_router, "_call_control", _fake_call_control) # The topic explorer talks to the broker via MQTT. Fake the session # registry + publish/retained so the endpoints can be exercised # without a live broker. from app.routers import topics as topics_router from app import explorer as explorer_module class FakeSession: def __init__(self, id, username, topic_filter): self.id = id self.username = username self.topic_filter = topic_filter self.buffer = [] # list of message dicts, index-tagged class FakeSessions: def __init__(self): self._sessions = {} self._counter = 0 def sweep(self): pass def start(self, username, topic_filter): self._counter += 1 sid = f"fake-{self._counter}" self._sessions[sid] = FakeSession(sid, username, topic_filter) return self._sessions[sid] def get(self, session_id): if session_id not in self._sessions: raise KeyError(session_id) return self._sessions[session_id] def stop(self, session_id): self._sessions.pop(session_id, None) def stop_all(self): self._sessions.clear() fake_sessions = FakeSessions() monkeypatch.setattr(explorer_module, "sessions", fake_sessions) def fake_messages_since(session_id, index): session = fake_sessions.get(session_id) return [dict(m) for m in session.buffer if m["index"] > index] def fake_list_retained(max_results=200, timeout=3.0): return [ { "index": 0, "topic": "site/config/level", "qos": 1, "retain": True, "received_at": 12345.0, "payload_b64": "MTIz", "payload_text": "123", "properties": None, } ] def fake_publish(topic, payload, qos, retain, timeout=10.0): fake_publish.calls.append((topic, payload, qos, retain)) fake_publish.calls = [] monkeypatch.setattr(explorer_module, "messages_since", fake_messages_since) monkeypatch.setattr(explorer_module, "list_retained", fake_list_retained) monkeypatch.setattr(explorer_module, "publish_message", fake_publish) # The router imports names from the module: patch there too. monkeypatch.setattr(topics_router.explorer, "sessions", fake_sessions) monkeypatch.setattr(topics_router.explorer, "messages_since", fake_messages_since) monkeypatch.setattr(topics_router.explorer, "list_retained", fake_list_retained) monkeypatch.setattr(topics_router.explorer, "publish_message", fake_publish) 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