mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
373 lines
12 KiB
Python
373 lines
12 KiB
Python
# SPDX-License-Identifier: 0BSD
|
|
|
|
import asyncio
|
|
import os
|
|
|
|
# Disable Landlock sandbox globally during backend testing to prevent process lockdown and PermissionError crashes.
|
|
os.environ["MESHCHAT_LANDLOCK"] = "0"
|
|
# Disable Windows AppContainer launcher path in tests (no-op on Linux, safe on Windows CI).
|
|
os.environ["MESHCHAT_APPCONTAINER"] = "0"
|
|
|
|
import socket
|
|
from contextlib import ExitStack
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
import RNS
|
|
|
|
from meshchatx.meshchat import ReticulumMeshChat
|
|
from meshchatx.src.backend.config_manager import ConfigManager
|
|
from meshchatx.src.backend.database import Database
|
|
from meshchatx.src.backend.database.provider import DatabaseProvider
|
|
from meshchatx.src.backend.database.schema import DatabaseSchema
|
|
from tests.backend.support.test_temp_dir import (
|
|
TEST_COVERAGE_DIR,
|
|
ensure_test_temp_dirs,
|
|
)
|
|
|
|
pytest_plugins = ["tests.backend.lxmf_local_self_support"]
|
|
|
|
|
|
def _ensure_coverage_data_dir() -> None:
|
|
ensure_test_temp_dirs()
|
|
cov_file = os.environ.get("COVERAGE_FILE")
|
|
if not cov_file:
|
|
return
|
|
parent = os.path.dirname(os.path.abspath(cov_file))
|
|
if parent:
|
|
os.makedirs(parent, exist_ok=True)
|
|
|
|
|
|
_ensure_coverage_data_dir()
|
|
|
|
|
|
def pytest_configure(config):
|
|
ensure_test_temp_dirs()
|
|
_ensure_coverage_data_dir()
|
|
worker = os.environ.get("PYTEST_XDIST_WORKER")
|
|
if worker:
|
|
os.makedirs(TEST_COVERAGE_DIR, exist_ok=True)
|
|
os.environ["COVERAGE_FILE"] = os.path.join(
|
|
str(TEST_COVERAGE_DIR),
|
|
f".coverage.{worker}",
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def loopback_available():
|
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
accepted = None
|
|
try:
|
|
server.settimeout(0.5)
|
|
client.settimeout(0.5)
|
|
server.bind(("127.0.0.1", 0))
|
|
server.listen(1)
|
|
port = server.getsockname()[1]
|
|
client.connect(("127.0.0.1", port))
|
|
accepted, _ = server.accept()
|
|
return True
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
if accepted is not None:
|
|
accepted.close()
|
|
client.close()
|
|
server.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def require_loopback_tcp(loopback_available):
|
|
if not loopback_available:
|
|
pytest.skip(
|
|
"Loopback TCP is blocked by local firewall/policy; skipping localhost integration test.",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def global_mocks():
|
|
with (
|
|
patch("meshchatx.meshchat.AsyncUtils") as mock_async_utils,
|
|
patch(
|
|
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
|
|
return_value=None,
|
|
),
|
|
patch("meshchatx.meshchat.generate_ssl_certificate", return_value=None),
|
|
):
|
|
# Mock run_async to properly close coroutines
|
|
def mock_run_async(coro):
|
|
if asyncio.iscoroutine(coro):
|
|
try:
|
|
# If it's a coroutine, we should close it if it's not being awaited
|
|
coro.close()
|
|
except RuntimeError:
|
|
pass
|
|
elif hasattr(coro, "__await__"):
|
|
# Handle other awaitables
|
|
pass
|
|
|
|
mock_async_utils.run_async.side_effect = mock_run_async
|
|
|
|
yield {
|
|
"async_utils": mock_async_utils,
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def cleanup_sqlite_connections():
|
|
yield
|
|
import gc
|
|
|
|
gc.collect()
|
|
|
|
|
|
def _uses_real_lxst_telephone(request) -> bool:
|
|
return request.node.get_closest_marker("lxst_real") is not None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def stub_lxst_telephone_unless_real(request):
|
|
"""Avoid LXST background announce threads during ReticulumMeshChat tests."""
|
|
if _uses_real_lxst_telephone(request):
|
|
yield
|
|
return
|
|
|
|
mock_instance = MagicMock()
|
|
mock_instance.busy = False
|
|
mock_instance.call_status = 3
|
|
mock_instance.active_call = None
|
|
mock_instance.destination.hexhash = "test_telephone_hexhash"
|
|
with patch(
|
|
"meshchatx.src.backend.telephone_manager.Telephone",
|
|
return_value=mock_instance,
|
|
):
|
|
yield mock_instance
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db(tmp_path):
|
|
db_path = os.path.join(tmp_path, "test_meshchat.db")
|
|
yield db_path
|
|
if os.path.exists(db_path):
|
|
os.remove(db_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def db(temp_db):
|
|
provider = DatabaseProvider(temp_db)
|
|
schema = DatabaseSchema(provider)
|
|
schema.initialize()
|
|
database = Database(temp_db)
|
|
yield database
|
|
database.close_all()
|
|
provider.close_all()
|
|
|
|
|
|
def _stub_map_data_manager(app):
|
|
"""Make patched MapDataManager awaitable and JSON-serializable for HTTP tests."""
|
|
mgr = app.map_data_manager
|
|
if mgr is None:
|
|
return
|
|
status = {
|
|
"aspect": "map-data-v1",
|
|
"running": True,
|
|
"destination_hash": "aa" * 16,
|
|
"display_name": "x",
|
|
"announce_enabled": False,
|
|
"announce_interval": 900,
|
|
"max_bytes": 524288,
|
|
"published_count": 0,
|
|
}
|
|
mgr.status.return_value = status
|
|
mgr.list_published.return_value = []
|
|
mgr.list_heard.return_value = []
|
|
mgr.announce.return_value = status
|
|
mgr.update_settings.return_value = status
|
|
mgr.publish_bytes.return_value = {
|
|
"map": {
|
|
"map_id": "a" * 16,
|
|
"name": "x",
|
|
"format": "geojson",
|
|
"size": 2,
|
|
},
|
|
"stripped": [],
|
|
}
|
|
mgr.unpublish.return_value = True
|
|
mgr.fetch_catalog = AsyncMock(
|
|
return_value={"destination_hash": "aa" * 16, "maps": []},
|
|
)
|
|
mgr.fetch_map_bytes = AsyncMock(return_value=b"{}")
|
|
mgr.add_as_overlay = AsyncMock(return_value={"ok": True})
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_app(db, tmp_path, temp_db):
|
|
real_identity_class = RNS.Identity
|
|
|
|
class MockIdentityClass(real_identity_class):
|
|
def __init__(self, *args, **kwargs):
|
|
self.hash = b"test_hash_32_bytes_long_01234567"
|
|
self.hexhash = self.hash.hex()
|
|
|
|
with ExitStack() as stack:
|
|
stack.enter_context(patch("RNS.Identity", MockIdentityClass))
|
|
stack.enter_context(patch("RNS.Reticulum"))
|
|
stack.enter_context(patch("RNS.Transport"))
|
|
stack.enter_context(patch("LXMF.LXMRouter"))
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
|
)
|
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.RnsFilesyncHandler"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
|
|
)
|
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.MapDataManager"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
|
)
|
|
stack.enter_context(
|
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
|
)
|
|
stack.enter_context(patch("threading.Thread"))
|
|
# threading.Thread is mocked to prevent background threads, but that also
|
|
# breaks asyncio.to_thread (used by the HTTP layer for DB queries). Run
|
|
# those calls synchronously so integration tests do not hang waiting for a
|
|
# MagicMock thread that never starts.
|
|
stack.enter_context(
|
|
patch(
|
|
"asyncio.to_thread",
|
|
side_effect=lambda fn, *args, **kwargs: fn(*args, **kwargs),
|
|
),
|
|
)
|
|
|
|
mock_id = MockIdentityClass()
|
|
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
|
|
|
stack.enter_context(
|
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
|
|
)
|
|
stack.enter_context(
|
|
patch.object(MockIdentityClass, "recall", return_value=mock_id),
|
|
)
|
|
stack.enter_context(
|
|
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
|
|
)
|
|
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"announce_loop",
|
|
new=MagicMock(return_value=None),
|
|
),
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"announce_sync_propagation_nodes",
|
|
new=MagicMock(return_value=None),
|
|
),
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"crawler_loop",
|
|
new=MagicMock(return_value=None),
|
|
),
|
|
)
|
|
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"auto_backup_loop",
|
|
new=MagicMock(return_value=None),
|
|
),
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"local_message_retention_loop",
|
|
new=MagicMock(return_value=None),
|
|
),
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
ReticulumMeshChat,
|
|
"send_config_to_websocket_clients",
|
|
return_value=None,
|
|
),
|
|
)
|
|
|
|
app = ReticulumMeshChat(
|
|
identity=mock_id,
|
|
storage_dir=str(tmp_path),
|
|
reticulum_config_dir=str(tmp_path),
|
|
)
|
|
|
|
# DatabaseProvider is a singleton; IdentityContext.setup() opens the identity DB
|
|
# and replaces the singleton. Recreate the test DB handle so config and DAOs use
|
|
# a live provider for the same path as the db fixture.
|
|
app.database = Database(temp_db)
|
|
app.current_context.config = ConfigManager(app.database)
|
|
app.config = app.current_context.config
|
|
if app.rrc_manager is not None:
|
|
app.rrc_manager.set_database(app.database)
|
|
app.websocket_broadcast = MagicMock(side_effect=lambda data: None)
|
|
app.demo_mode = False
|
|
app.altcha_enabled = False
|
|
_stub_map_data_manager(app)
|
|
|
|
yield app
|
|
app.teardown_identity()
|
|
|
|
|
|
async def fetch_api_csrf_headers(client):
|
|
response = await client.get("/api/v1/auth/csrf")
|
|
assert response.status == 200
|
|
payload = await response.json()
|
|
token = payload.get("csrf_token")
|
|
assert token
|
|
return {"X-CSRF-Token": token}
|
|
|
|
|
|
def extend_meshchat_middlewares(aio_app, middlewares):
|
|
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = middlewares
|
|
aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw])
|
|
|
|
|
|
def pytest_collection_modifyitems(session, config, items):
|
|
shard_index = os.environ.get("PYTEST_SHARD_INDEX")
|
|
total_shards = os.environ.get("PYTEST_TOTAL_SHARDS")
|
|
if shard_index is not None and total_shards is not None:
|
|
try:
|
|
shard_index = int(shard_index)
|
|
total_shards = int(total_shards)
|
|
if total_shards > 1 and 0 <= shard_index < total_shards:
|
|
items.sort(key=lambda item: item.nodeid)
|
|
items[:] = [
|
|
item
|
|
for i, item in enumerate(items)
|
|
if i % total_shards == shard_index
|
|
]
|
|
except ValueError:
|
|
pass
|