From bbfc45925e82c364519b9d796758003365bc9005 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 11 Aug 2026 22:11:18 +0200 Subject: feat(node): Phase 11 — production-ready daemon with WebRTC, WS, chat, HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node daemon was previously a skeleton that only started QUIC/TCP servers and the local web UI. All browser-facing functionality (WebRTC, hub WebSocket, chat store, HTTP file API) lived in QE demo scripts. This rewrites daemon.py to be fully self-contained: - WebRTC transport for browser clients (aiortc DataChannel) - Hub WebSocket task (signaling, revocations, WebRTC offers) - ChatStore per group (SQLite in ~/.local/share/meshbay/) - HTTP file API per group (create_http_app on configured port) - Graceful shutdown (all transports, stores, tasks) - hub_client: _ws tracking + send_ws() for chat notifications - config: data_dir field for persistent state - systemd: security hardening (ProtectSystem, StateDirectory) Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-node/tests/test_daemon.py | 171 +++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 packages/meshbay-node/tests/test_daemon.py (limited to 'packages/meshbay-node/tests') diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py new file mode 100644 index 0000000..e74f7fc --- /dev/null +++ b/packages/meshbay-node/tests/test_daemon.py @@ -0,0 +1,171 @@ +""" +Integration test: Node daemon wires all components correctly. + +Phase 11 — verifies that NodeDaemon creates chat stores, WebRTC transport, +and shuts down cleanly. Hub interaction is mocked. +""" + +import asyncio +import os + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from unittest.mock import AsyncMock, MagicMock, patch + +from meshbay_common.crypto import generate_gek +from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig +from meshbay_node.daemon import NodeDaemon + + +@pytest.fixture +def sk_hub(): + return Ed25519PrivateKey.generate() + + +@pytest.fixture +def hub_pk_pem(sk_hub): + return sk_hub.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + + +@pytest.fixture +def gek(): + return generate_gek() + + +@pytest.fixture +def shared_dir(tmp_path): + d = tmp_path / "shared" + d.mkdir() + (d / "test.bin").write_bytes(os.urandom(2048)) + (d / "hello.txt").write_bytes(b"hello daemon test " * 50) + return d + + +@pytest.fixture +def node_config(tmp_path, shared_dir): + return Config( + hub=HubConfig(url="http://localhost:9999", username="testuser", password="testpass"), + node=NodeConfig(port=29000, quic_port=29010, http_port=29001, ui_port=28000), + groups=[GroupConfig( + id="g" * 32, + name="test-group", + shared_dir=str(shared_dir), + visibility="private", + port=29000, + quic_port=29010, + http_port=29001, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + + +@pytest.mark.asyncio +async def test_daemon_creates_chat_store(tmp_path, node_config, gek, hub_pk_pem): + """Daemon creates ChatStore for each group and shuts down cleanly.""" + daemon = NodeDaemon(node_config) + + sk_node = Ed25519PrivateKey.generate() + mock_keys = MagicMock() + mock_keys.sk_ed25519 = sk_node + mock_keys.pk_ed25519_b64 = "test" + mock_keys.pk_x25519_b64 = "test" + + mock_session = MagicMock() + mock_session.node_id = "node123" + mock_session.user_id = "user123" + mock_session.hub_pk_pem = hub_pk_pem + + with patch("meshbay_node.daemon.load_or_create_keystore", return_value=mock_keys), \ + patch("meshbay_node.daemon.HubClient") as MockHub: + + hub_instance = AsyncMock() + hub_instance.startup = AsyncMock(return_value=mock_session) + hub_instance.fetch_gek = AsyncMock(return_value=gek) + hub_instance.maintain_ws = AsyncMock() + hub_instance.send_ws = AsyncMock() + hub_instance._ws = None + hub_instance.close = AsyncMock() + hub_instance.__aenter__ = AsyncMock(return_value=hub_instance) + hub_instance.__aexit__ = AsyncMock(return_value=False) + MockHub.return_value = hub_instance + + shutdown_event = asyncio.Event() + + async def mock_maintain_ws(**kwargs): + await shutdown_event.wait() + + hub_instance.maintain_ws = mock_maintain_ws + + async def run_daemon(): + with patch("signal.SIGINT", 2), \ + patch("signal.SIGTERM", 15): + try: + await asyncio.wait_for(daemon.run(), timeout=5) + except (asyncio.TimeoutError, Exception): + pass + + task = asyncio.create_task(run_daemon()) + await asyncio.sleep(1) + + assert daemon._state["status"] == "running" + group_id = "g" * 32 + assert group_id in daemon._chat_stores + assert daemon._chat_stores[group_id]._db is not None + + if daemon._webrtc: + assert "chat_store" in daemon._webrtc._ctx + assert "hub_ws" in daemon._webrtc._ctx + assert "node_user_id" in daemon._webrtc._ctx + assert daemon._webrtc._ctx["node_user_id"] == "user123" + + shutdown_event.set() + await daemon._shutdown() + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + for store in daemon._chat_stores.values(): + assert store._db is None + + +@pytest.mark.asyncio +async def test_daemon_no_groups_exits(tmp_path): + """Daemon with no valid groups exits cleanly.""" + config = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser", password="testpass"), + node=NodeConfig(), + groups=[GroupConfig(id="", name="empty", shared_dir="")], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + daemon = NodeDaemon(config) + + sk_node = Ed25519PrivateKey.generate() + mock_keys = MagicMock() + mock_keys.sk_ed25519 = sk_node + mock_keys.pk_ed25519_b64 = "test" + + mock_session = MagicMock() + mock_session.node_id = "node123" + mock_session.user_id = "user123" + mock_session.hub_pk_pem = b"pem" + + with patch("meshbay_node.daemon.load_or_create_keystore", return_value=mock_keys), \ + patch("meshbay_node.daemon.HubClient") as MockHub: + + hub_instance = AsyncMock() + hub_instance.startup = AsyncMock(return_value=mock_session) + hub_instance.close = AsyncMock() + hub_instance.__aenter__ = AsyncMock(return_value=hub_instance) + hub_instance.__aexit__ = AsyncMock(return_value=False) + MockHub.return_value = hub_instance + + await daemon.run() + + assert daemon._state["status"] == "starting" + assert len(daemon._chat_stores) == 0 -- cgit v1.2.3