mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-08-14 07:59:46 +00:00
Add tests for database backup logic and interface discovery configuration
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -75,4 +76,192 @@ def test_database_auto_backup_logic(temp_dir):
|
||||
assert os.path.exists(result["path"])
|
||||
|
||||
backup_dir = os.path.join(temp_dir, "database-backups")
|
||||
assert len(os.listdir(backup_dir)) == 1
|
||||
zips = [f for f in os.listdir(backup_dir) if f.endswith(".zip")]
|
||||
assert len(zips) == 1
|
||||
|
||||
|
||||
def test_backup_baseline_created_on_first_backup(temp_dir):
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
db.backup_database(temp_dir)
|
||||
baseline_path = os.path.join(temp_dir, "database-backups", "backup-baseline.json")
|
||||
assert os.path.exists(baseline_path)
|
||||
with open(baseline_path) as f:
|
||||
data = json.load(f)
|
||||
assert "message_count" in data
|
||||
assert "total_bytes" in data
|
||||
assert "timestamp" in data
|
||||
|
||||
|
||||
def test_backup_suspicious_when_messages_gone_skips_cleanup_and_baseline(temp_dir):
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
db.messages.upsert_lxmf_message({
|
||||
"hash": "h1", "source_hash": "s", "destination_hash": "d", "peer_hash": "p",
|
||||
"state": "delivered", "progress": 1.0, "is_incoming": 1, "method": "direct",
|
||||
"delivery_attempts": 0, "next_delivery_attempt_at": None, "title": "t", "content": "c",
|
||||
"fields": "{}", "timestamp": 0, "rssi": None, "snr": None, "quality": None,
|
||||
"is_spam": 0, "reply_to_hash": None,
|
||||
})
|
||||
result1 = db.backup_database(temp_dir, max_count=3)
|
||||
assert result1.get("suspicious") is not True
|
||||
backup_dir = os.path.join(temp_dir, "database-backups")
|
||||
zip_count_after_first = sum(1 for f in os.listdir(backup_dir) if f.endswith(".zip"))
|
||||
assert zip_count_after_first == 1
|
||||
with open(os.path.join(backup_dir, "backup-baseline.json")) as f:
|
||||
baseline_after_first = json.load(f)
|
||||
assert baseline_after_first["message_count"] == 1
|
||||
|
||||
db.messages.delete_all_lxmf_messages()
|
||||
assert db.messages.count_lxmf_messages() == 0
|
||||
result2 = db.backup_database(temp_dir, max_count=3)
|
||||
assert result2.get("suspicious") is True
|
||||
assert "baseline" in result2
|
||||
assert result2["baseline"]["message_count"] == 1
|
||||
assert result2["current_stats"]["message_count"] == 0
|
||||
zip_count_after_suspicious = sum(1 for f in os.listdir(backup_dir) if f.endswith(".zip"))
|
||||
assert zip_count_after_suspicious == 2
|
||||
assert any("SUSPICIOUS" in f for f in os.listdir(backup_dir) if f.endswith(".zip"))
|
||||
with open(os.path.join(backup_dir, "backup-baseline.json")) as f:
|
||||
baseline_after_suspicious = json.load(f)
|
||||
assert baseline_after_suspicious["message_count"] == 1
|
||||
|
||||
|
||||
def test_backup_suspicious_when_size_collapsed_skips_cleanup(temp_dir):
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
for i in range(200):
|
||||
db.messages.upsert_lxmf_message({
|
||||
"hash": f"h{i}", "source_hash": "s", "destination_hash": "d", "peer_hash": "p",
|
||||
"state": "delivered", "progress": 1.0, "is_incoming": 1, "method": "direct",
|
||||
"delivery_attempts": 0, "next_delivery_attempt_at": None, "title": "t", "content": "x" * 500,
|
||||
"fields": "{}", "timestamp": float(i), "rssi": None, "snr": None, "quality": None,
|
||||
"is_spam": 0, "reply_to_hash": None,
|
||||
})
|
||||
db.backup_database(temp_dir, max_count=3)
|
||||
baseline_path = os.path.join(temp_dir, "database-backups", "backup-baseline.json")
|
||||
with open(baseline_path) as f:
|
||||
baseline = json.load(f)
|
||||
assert baseline["total_bytes"] > 100_000
|
||||
db.messages.delete_all_lxmf_messages()
|
||||
db.execute_sql("VACUUM")
|
||||
result = db.backup_database(temp_dir, max_count=3)
|
||||
assert result.get("suspicious") is True
|
||||
backup_dir = os.path.join(temp_dir, "database-backups")
|
||||
zips = [f for f in os.listdir(backup_dir) if f.endswith(".zip")]
|
||||
assert len(zips) >= 2
|
||||
|
||||
|
||||
def test_backup_normal_rotation_and_baseline_update(temp_dir):
|
||||
import time
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
db.backup_database(temp_dir, max_count=2)
|
||||
time.sleep(1.1)
|
||||
db.backup_database(temp_dir, max_count=2)
|
||||
time.sleep(1.1)
|
||||
db.backup_database(temp_dir, max_count=2)
|
||||
backup_dir = os.path.join(temp_dir, "database-backups")
|
||||
zips = sorted([f for f in os.listdir(backup_dir) if f.endswith(".zip")])
|
||||
assert len(zips) == 2
|
||||
assert os.path.exists(os.path.join(backup_dir, "backup-baseline.json"))
|
||||
|
||||
|
||||
def test_backup_failure_does_not_remove_existing_backups(temp_dir):
|
||||
from unittest.mock import patch
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
db.backup_database(temp_dir)
|
||||
backup_dir = os.path.join(temp_dir, "database-backups")
|
||||
existing = [f for f in os.listdir(backup_dir) if f.endswith(".zip")]
|
||||
assert len(existing) == 1
|
||||
with patch.object(db, "_backup_to_zip", side_effect=OSError("disk full")):
|
||||
with pytest.raises(OSError):
|
||||
db.backup_database(temp_dir, max_count=1)
|
||||
still_there = [f for f in os.listdir(backup_dir) if f.endswith(".zip")]
|
||||
assert len(still_there) == 1
|
||||
|
||||
|
||||
def test_check_db_health_at_open_no_baseline_ok(temp_dir):
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
issues = db.check_db_health_at_open(temp_dir)
|
||||
assert issues == []
|
||||
|
||||
|
||||
def test_check_db_health_at_open_baseline_suspicious_content(temp_dir):
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
db.messages.upsert_lxmf_message({
|
||||
"hash": "h1", "source_hash": "s", "destination_hash": "d", "peer_hash": "p",
|
||||
"state": "delivered", "progress": 1.0, "is_incoming": 1, "method": "direct",
|
||||
"delivery_attempts": 0, "next_delivery_attempt_at": None, "title": "t", "content": "c",
|
||||
"fields": "{}", "timestamp": 0, "rssi": None, "snr": None, "quality": None,
|
||||
"is_spam": 0, "reply_to_hash": None,
|
||||
})
|
||||
db.backup_database(temp_dir)
|
||||
db.messages.delete_all_lxmf_messages()
|
||||
issues = db.check_db_health_at_open(temp_dir)
|
||||
assert len(issues) >= 1
|
||||
assert any("anomaly" in i.lower() or "messages" in i for i in issues)
|
||||
|
||||
|
||||
def test_check_db_health_at_open_integrity_fail(temp_dir):
|
||||
from unittest.mock import patch
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
with patch.object(db.provider, "integrity_check", return_value=[("corrupt",)]):
|
||||
issues = db.check_db_health_at_open(temp_dir)
|
||||
assert len(issues) >= 1
|
||||
assert any("integrity" in i.lower() for i in issues)
|
||||
|
||||
|
||||
def test_check_db_health_at_close_no_issues(temp_dir):
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
issues = db.check_db_health_at_close(temp_dir)
|
||||
assert issues == []
|
||||
|
||||
|
||||
def test_check_db_health_at_close_integrity_fail(temp_dir):
|
||||
from unittest.mock import patch
|
||||
db_path = os.path.join(temp_dir, "test.db")
|
||||
db = Database(db_path)
|
||||
db.initialize()
|
||||
with patch.object(db.provider, "integrity_check", return_value=[("corrupt",)]):
|
||||
issues = db.check_db_health_at_close(temp_dir)
|
||||
assert len(issues) >= 1
|
||||
assert any("integrity" in i.lower() for i in issues)
|
||||
|
||||
|
||||
def test_is_backup_suspicious_does_not_mistrigger_empty_baseline():
|
||||
from meshchatx.src.backend.database import Database
|
||||
db = Database(":memory:")
|
||||
db.initialize()
|
||||
assert db._is_backup_suspicious({"message_count": 0, "total_bytes": 0}, None) is False
|
||||
assert db._is_backup_suspicious({"message_count": 10, "total_bytes": 1000}, None) is False
|
||||
|
||||
|
||||
def test_is_backup_suspicious_does_not_mistrigger_legitimate_empty():
|
||||
from meshchatx.src.backend.database import Database
|
||||
db = Database(":memory:")
|
||||
db.initialize()
|
||||
baseline = {"message_count": 0, "total_bytes": 5000}
|
||||
assert db._is_backup_suspicious({"message_count": 0, "total_bytes": 5000}, baseline) is False
|
||||
|
||||
|
||||
def test_is_backup_suspicious_does_not_mistrigger_small_db():
|
||||
from meshchatx.src.backend.database import Database
|
||||
db = Database(":memory:")
|
||||
db.initialize()
|
||||
baseline = {"message_count": 5, "total_bytes": 50_000}
|
||||
assert db._is_backup_suspicious({"message_count": 5, "total_bytes": 55_000}, baseline) is False
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Tests that HTTPS/WSS is used so local traffic cannot be sniffed by other apps.
|
||||
Server must speak TLS only on the API/WS port; plain HTTP must not be accepted.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import tempfile
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
|
||||
def _make_self_signed_cert_and_key(cert_path: str, key_path: str) -> None:
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
backend=default_backend(),
|
||||
)
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
|
||||
],
|
||||
)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.now(UTC))
|
||||
.not_valid_after(datetime.now(UTC) + timedelta(days=365))
|
||||
.sign(private_key, hashes.SHA256(), default_backend())
|
||||
)
|
||||
os.makedirs(os.path.dirname(cert_path) or ".", exist_ok=True)
|
||||
with open(cert_path, "wb") as f:
|
||||
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
||||
with open(key_path, "wb") as f:
|
||||
f.write(
|
||||
private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_storage():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ssl_context_and_cert(temp_storage):
|
||||
cert_dir = temp_storage
|
||||
cert_path = os.path.join(cert_dir, "cert.pem")
|
||||
key_path = os.path.join(cert_dir, "key.pem")
|
||||
_make_self_signed_cert_and_key(cert_path, key_path)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(cert_path, key_path)
|
||||
return ctx, cert_path, key_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_https_serves_over_tls_only_plain_http_gets_no_http_response(
|
||||
ssl_context_and_cert,
|
||||
):
|
||||
"""
|
||||
Server started with ssl_context must not respond to plain HTTP.
|
||||
A local sniffer connecting with raw TCP and sending HTTP would get
|
||||
TLS handshake bytes or connection closure, not plaintext HTTP response.
|
||||
"""
|
||||
ssl_context, _, _ = ssl_context_and_cert
|
||||
app = web.Application()
|
||||
|
||||
async def root_handler(_request):
|
||||
return web.Response(text="ok")
|
||||
|
||||
app.router.add_get("/", root_handler)
|
||||
runner = web.AppRunner(app, keepalive_timeout=0)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ssl_context)
|
||||
await site.start()
|
||||
try:
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
client_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
client_ctx.check_hostname = False
|
||||
client_ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
resp = await session.get(
|
||||
f"https://127.0.0.1:{port}/",
|
||||
ssl=client_ctx,
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert (await resp.text()) == "ok"
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1.0)
|
||||
try:
|
||||
sock.connect(("127.0.0.1", port))
|
||||
sock.sendall(b"GET / HTTP/1.0\r\n\r\n")
|
||||
try:
|
||||
raw = sock.recv(1024)
|
||||
except socket.timeout:
|
||||
raw = b""
|
||||
finally:
|
||||
sock.close()
|
||||
assert not raw.startswith(b"HTTP/"), (
|
||||
"Server must not respond with plain HTTP when TLS is enabled; "
|
||||
"plaintext would allow local side-sniffing"
|
||||
)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wss_over_same_tls_port(ssl_context_and_cert):
|
||||
"""
|
||||
WebSocket upgrade over the same TLS port uses WSS (encrypted).
|
||||
Verifies that a WS endpoint is reachable only via TLS.
|
||||
"""
|
||||
ssl_context, _, _ = ssl_context_and_cert
|
||||
app = web.Application()
|
||||
|
||||
async def ws_handler(request):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
await ws.close()
|
||||
return ws
|
||||
|
||||
app.router.add_get("/ws", ws_handler)
|
||||
runner = web.AppRunner(app, keepalive_timeout=0)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ssl_context)
|
||||
await site.start()
|
||||
try:
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
client_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
client_ctx.check_hostname = False
|
||||
client_ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.ws_connect(
|
||||
f"wss://127.0.0.1:{port}/ws",
|
||||
ssl=client_ctx,
|
||||
) as ws:
|
||||
msg = await ws.receive()
|
||||
assert msg.type in (
|
||||
aiohttp.WSMsgType.CLOSE,
|
||||
aiohttp.WSMsgType.CLOSING,
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
|
||||
"reticulum": {
|
||||
"discover_interfaces": "true",
|
||||
"interface_discovery_sources": "abc,def",
|
||||
"interface_discovery_whitelist": "tcp-*,10.0.*",
|
||||
"interface_discovery_blacklist": "tcp-bad,*:9999",
|
||||
"required_discovery_value": "16",
|
||||
"autoconnect_discovered_interfaces": "2",
|
||||
"network_identity": "/tmp/net_id",
|
||||
@@ -93,6 +95,8 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
|
||||
get_data = json.loads(get_response.body)
|
||||
assert get_data["discovery"]["discover_interfaces"] == "true"
|
||||
assert get_data["discovery"]["interface_discovery_sources"] == "abc,def"
|
||||
assert get_data["discovery"]["interface_discovery_whitelist"] == "tcp-*,10.0.*"
|
||||
assert get_data["discovery"]["interface_discovery_blacklist"] == "tcp-bad,*:9999"
|
||||
assert get_data["discovery"]["required_discovery_value"] == "16"
|
||||
assert get_data["discovery"]["autoconnect_discovered_interfaces"] == "2"
|
||||
assert get_data["discovery"]["network_identity"] == "/tmp/net_id"
|
||||
@@ -101,6 +105,8 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
|
||||
new_config = {
|
||||
"discover_interfaces": False,
|
||||
"interface_discovery_sources": "",
|
||||
"interface_discovery_whitelist": "peer-*,172.16.*",
|
||||
"interface_discovery_blacklist": "",
|
||||
"required_discovery_value": 18,
|
||||
"autoconnect_discovered_interfaces": 5,
|
||||
"network_identity": "/tmp/other_id",
|
||||
@@ -115,17 +121,154 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
|
||||
patch_data = json.loads(patch_response.body)
|
||||
assert patch_data["discovery"]["discover_interfaces"] is False
|
||||
assert patch_data["discovery"]["interface_discovery_sources"] is None
|
||||
assert patch_data["discovery"]["interface_discovery_whitelist"] == "peer-*,172.16.*"
|
||||
assert patch_data["discovery"]["interface_discovery_blacklist"] is None
|
||||
assert patch_data["discovery"]["required_discovery_value"] == 18
|
||||
assert patch_data["discovery"]["autoconnect_discovered_interfaces"] == 5
|
||||
assert patch_data["discovery"]["network_identity"] == "/tmp/other_id"
|
||||
assert config["reticulum"]["discover_interfaces"] is False
|
||||
assert "interface_discovery_sources" not in config["reticulum"]
|
||||
assert config["reticulum"]["interface_discovery_whitelist"] == "peer-*,172.16.*"
|
||||
assert "interface_discovery_blacklist" not in config["reticulum"]
|
||||
assert config["reticulum"]["required_discovery_value"] == 18
|
||||
assert config["reticulum"]["autoconnect_discovered_interfaces"] == 5
|
||||
assert config["reticulum"]["network_identity"] == "/tmp/other_id"
|
||||
assert config.write_called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovered_interfaces_respect_whitelist_and_blacklist(temp_dir):
|
||||
config = ConfigDict(
|
||||
{
|
||||
"reticulum": {
|
||||
"interface_discovery_whitelist": "peer-*,10.0.*",
|
||||
"interface_discovery_blacklist": "*:9999,bad-*",
|
||||
},
|
||||
"interfaces": {},
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("meshchatx.meshchat.generate_ssl_certificate"),
|
||||
patch("RNS.Reticulum") as mock_rns,
|
||||
patch("RNS.Transport"),
|
||||
patch("LXMF.LXMRouter"),
|
||||
patch("meshchatx.meshchat.InterfaceDiscovery") as mock_discovery_cls,
|
||||
):
|
||||
mock_reticulum = mock_rns.return_value
|
||||
mock_reticulum.config = config
|
||||
mock_reticulum.configpath = "/tmp/mock_config"
|
||||
mock_reticulum.is_connected_to_shared_instance = False
|
||||
mock_reticulum.transport_enabled.return_value = True
|
||||
mock_reticulum.get_interface_stats.return_value = {"interfaces": []}
|
||||
|
||||
mock_discovery = mock_discovery_cls.return_value
|
||||
mock_discovery.list_discovered_interfaces.return_value = [
|
||||
{
|
||||
"name": "peer-good-1",
|
||||
"type": "TCPClientInterface",
|
||||
"reachable_on": "10.0.0.7",
|
||||
"port": 4242,
|
||||
},
|
||||
{
|
||||
"name": "peer-blocked-port",
|
||||
"type": "TCPClientInterface",
|
||||
"reachable_on": "10.0.0.8",
|
||||
"port": 9999,
|
||||
},
|
||||
{
|
||||
"name": "bad-node",
|
||||
"type": "TCPClientInterface",
|
||||
"reachable_on": "10.0.0.9",
|
||||
"port": 4242,
|
||||
},
|
||||
{
|
||||
"name": "other-network",
|
||||
"type": "TCPClientInterface",
|
||||
"reachable_on": "192.168.1.10",
|
||||
"port": 4242,
|
||||
},
|
||||
]
|
||||
|
||||
app_instance = ReticulumMeshChat(
|
||||
identity=build_identity(),
|
||||
storage_dir=temp_dir,
|
||||
reticulum_config_dir=temp_dir,
|
||||
)
|
||||
|
||||
handler = await find_route_handler(
|
||||
app_instance,
|
||||
"/api/v1/reticulum/discovered-interfaces",
|
||||
"GET",
|
||||
)
|
||||
assert handler
|
||||
|
||||
response = await handler(MagicMock())
|
||||
data = json.loads(response.body)
|
||||
interfaces = data["interfaces"]
|
||||
|
||||
assert len(interfaces) == 1
|
||||
assert interfaces[0]["name"] == "peer-good-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_patch_sanitizes_whitelist_blacklist_values(temp_dir):
|
||||
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
||||
|
||||
with (
|
||||
patch("meshchatx.meshchat.generate_ssl_certificate"),
|
||||
patch("RNS.Reticulum") as mock_rns,
|
||||
patch("RNS.Transport"),
|
||||
patch("LXMF.LXMRouter"),
|
||||
):
|
||||
mock_reticulum = mock_rns.return_value
|
||||
mock_reticulum.config = config
|
||||
mock_reticulum.configpath = "/tmp/mock_config"
|
||||
mock_reticulum.is_connected_to_shared_instance = False
|
||||
mock_reticulum.transport_enabled.return_value = True
|
||||
|
||||
app_instance = ReticulumMeshChat(
|
||||
identity=build_identity(),
|
||||
storage_dir=temp_dir,
|
||||
reticulum_config_dir=temp_dir,
|
||||
)
|
||||
|
||||
patch_handler = await find_route_handler(
|
||||
app_instance,
|
||||
"/api/v1/reticulum/discovery",
|
||||
"PATCH",
|
||||
)
|
||||
assert patch_handler
|
||||
|
||||
payload = {
|
||||
"interface_discovery_whitelist": "peer-1,\npeer-1,host:4242,\r\n,\n",
|
||||
"interface_discovery_blacklist": ["bad-node", "bad-node", "evil,\nentry"],
|
||||
}
|
||||
|
||||
class PatchRequest:
|
||||
@staticmethod
|
||||
async def json():
|
||||
return payload
|
||||
|
||||
response = await patch_handler(PatchRequest())
|
||||
data = json.loads(response.body)
|
||||
|
||||
assert data["discovery"]["interface_discovery_whitelist"] == "peer-1,host:4242"
|
||||
assert data["discovery"]["interface_discovery_blacklist"] == "bad-node,evilentry"
|
||||
assert config["reticulum"]["interface_discovery_whitelist"] == "peer-1,host:4242"
|
||||
assert config["reticulum"]["interface_discovery_blacklist"] == "bad-node,evilentry"
|
||||
assert config.write_called
|
||||
|
||||
|
||||
def test_filter_discovered_interfaces_handles_non_list_inputs():
|
||||
result = ReticulumMeshChat.filter_discovered_interfaces(
|
||||
{"unexpected": "shape"},
|
||||
"peer-*",
|
||||
"bad-*",
|
||||
)
|
||||
assert result == {"unexpected": "shape"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interface_add_includes_discovery_fields(temp_dir):
|
||||
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from meshchatx.meshchat import ReticulumMeshChat
|
||||
from meshchatx.src.backend.colour_utils import ColourUtils
|
||||
from meshchatx.src.backend.identity_manager import IdentityManager
|
||||
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
|
||||
@@ -317,6 +318,46 @@ def test_interface_config_parser_structured(names, keys, values):
|
||||
pytest.fail(f"InterfaceConfigParser.parse failed on structured input: {e}")
|
||||
|
||||
|
||||
@given(
|
||||
interfaces=st.lists(
|
||||
st.dictionaries(
|
||||
keys=st.sampled_from(
|
||||
[
|
||||
"name",
|
||||
"type",
|
||||
"reachable_on",
|
||||
"target_host",
|
||||
"remote",
|
||||
"listen_ip",
|
||||
"port",
|
||||
"target_port",
|
||||
"listen_port",
|
||||
"discovery_hash",
|
||||
"transport_id",
|
||||
"network_id",
|
||||
]
|
||||
),
|
||||
values=st.one_of(st.text(), st.integers(), st.none()),
|
||||
max_size=12,
|
||||
),
|
||||
max_size=40,
|
||||
),
|
||||
whitelist=st.one_of(st.text(), st.lists(st.text(), max_size=20), st.none()),
|
||||
blacklist=st.one_of(st.text(), st.lists(st.text(), max_size=20), st.none()),
|
||||
)
|
||||
def test_discovery_filter_robustness(interfaces, whitelist, blacklist):
|
||||
try:
|
||||
filtered = ReticulumMeshChat.filter_discovered_interfaces(
|
||||
interfaces,
|
||||
whitelist,
|
||||
blacklist,
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(f"Discovery filtering crashed: {e}")
|
||||
assert isinstance(filtered, list)
|
||||
assert len(filtered) <= len(interfaces)
|
||||
|
||||
|
||||
# Strategy for a database message row
|
||||
st_db_message = st.dictionaries(
|
||||
keys=st.sampled_from(
|
||||
@@ -870,3 +911,39 @@ def test_parse_lxmf_display_name_extended(aspect, data):
|
||||
assert isinstance(result, str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
MIN_SIZE_RATIO = 0.2
|
||||
|
||||
|
||||
def _is_backup_suspicious_reference(current_stats, baseline):
|
||||
if not baseline:
|
||||
return False
|
||||
prev_count = baseline.get("message_count", 0)
|
||||
prev_bytes = baseline.get("total_bytes", 0)
|
||||
curr_count = current_stats.get("message_count", 0)
|
||||
curr_bytes = current_stats.get("total_bytes", 0)
|
||||
if prev_count > 0 and curr_count == 0:
|
||||
return True
|
||||
if prev_bytes > 100_000 and curr_bytes < prev_bytes * MIN_SIZE_RATIO:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@given(
|
||||
prev_count=st.integers(min_value=0, max_value=1_000_000),
|
||||
prev_bytes=st.integers(min_value=0, max_value=10_000_000),
|
||||
curr_count=st.integers(min_value=0, max_value=1_000_000),
|
||||
curr_bytes=st.integers(min_value=0, max_value=10_000_000),
|
||||
)
|
||||
@settings(suppress_health_check=[HealthCheck.too_slow])
|
||||
def test_is_backup_suspicious_property(prev_count, prev_bytes, curr_count, curr_bytes):
|
||||
from meshchatx.src.backend.database import Database
|
||||
|
||||
baseline = {"message_count": prev_count, "total_bytes": prev_bytes}
|
||||
current_stats = {"message_count": curr_count, "total_bytes": curr_bytes}
|
||||
db = Database(":memory:")
|
||||
db.initialize()
|
||||
result = db._is_backup_suspicious(current_stats, baseline)
|
||||
expected = _is_backup_suspicious_reference(current_stats, baseline)
|
||||
assert result == expected
|
||||
|
||||
@@ -13,6 +13,21 @@ from hypothesis import strategies as st
|
||||
from meshchatx.meshchat import ReticulumMeshChat
|
||||
|
||||
|
||||
@given(value=st.one_of(st.text(), st.lists(st.text(), min_size=0, max_size=20)))
|
||||
@settings(deadline=None)
|
||||
def test_discovery_pattern_sanitization_never_emits_unsafe_tokens(value):
|
||||
patterns = ReticulumMeshChat.sanitize_discovery_patterns(value)
|
||||
assert isinstance(patterns, list)
|
||||
assert len(patterns) <= 128
|
||||
for token in patterns:
|
||||
assert token
|
||||
assert len(token) <= 128
|
||||
assert "," not in token
|
||||
assert "\n" not in token
|
||||
assert "\r" not in token
|
||||
assert token.strip() == token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app():
|
||||
# Save real Identity class to use as base for our mock class
|
||||
@@ -1280,6 +1295,346 @@ def test_nomadnet_page_content_fuzzing(mock_app, page_content):
|
||||
convert_nomadnet_field_data_to_map(page_content)
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(path_data=st.one_of(st.text(min_size=0, max_size=10000), st.none()))
|
||||
def test_nomadnet_string_data_to_map_fuzzing(mock_app, path_data):
|
||||
"""Fuzz NomadNet path variable string parsing (e.g. after backtick in page path)."""
|
||||
from meshchatx.src.backend.nomadnet_utils import convert_nomadnet_string_data_to_map
|
||||
|
||||
result = convert_nomadnet_string_data_to_map(path_data)
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
download_id=st.one_of(
|
||||
st.integers(),
|
||||
st.text(min_size=0, max_size=200),
|
||||
st.none(),
|
||||
),
|
||||
)
|
||||
def test_nomadnet_download_cancel_fuzzing(mock_app, download_id):
|
||||
"""Fuzz nomadnet.download.cancel WebSocket handler."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(
|
||||
MagicMock(),
|
||||
{"type": "nomadnet.download.cancel", "download_id": download_id},
|
||||
),
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
destination_hash=st.text(min_size=0, max_size=200),
|
||||
page_path=st.text(min_size=0, max_size=2000),
|
||||
)
|
||||
def test_nomadnet_page_archives_get_fuzzing(mock_app, destination_hash, page_path):
|
||||
"""Fuzz nomadnet.page.archives.get WebSocket handler."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(
|
||||
MagicMock(),
|
||||
{
|
||||
"type": "nomadnet.page.archives.get",
|
||||
"destination_hash": destination_hash,
|
||||
"page_path": page_path,
|
||||
},
|
||||
),
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
archive_id=st.one_of(
|
||||
st.integers(),
|
||||
st.text(min_size=0, max_size=100),
|
||||
st.floats(allow_nan=False, allow_infinity=False),
|
||||
st.none(),
|
||||
),
|
||||
)
|
||||
def test_nomadnet_page_archive_load_fuzzing(mock_app, archive_id):
|
||||
"""Fuzz nomadnet.page.archive.load and get_archived_page_by_id."""
|
||||
mock_app.database.misc.get_archived_page_by_id(archive_id)
|
||||
import asyncio
|
||||
|
||||
mock_app.database.misc.get_archived_page_by_id.return_value = None
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(
|
||||
MagicMock(),
|
||||
{"type": "nomadnet.page.archive.load", "archive_id": archive_id},
|
||||
),
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
destination_hash=st.text(min_size=0, max_size=200),
|
||||
page_path=st.text(min_size=0, max_size=2000),
|
||||
content=st.one_of(
|
||||
st.text(min_size=0, max_size=50000),
|
||||
st.binary(min_size=0, max_size=10000),
|
||||
),
|
||||
)
|
||||
def test_nomadnet_page_archive_add_fuzzing(
|
||||
mock_app, destination_hash, page_path, content
|
||||
):
|
||||
"""Fuzz nomadnet.page.archive.add WebSocket handler and archive_page."""
|
||||
import asyncio
|
||||
|
||||
content_str = (
|
||||
content.decode("utf-8", errors="replace")
|
||||
if isinstance(content, bytes)
|
||||
else content
|
||||
)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(
|
||||
MagicMock(),
|
||||
{
|
||||
"type": "nomadnet.page.archive.add",
|
||||
"destination_hash": destination_hash,
|
||||
"page_path": page_path,
|
||||
"content": content_str,
|
||||
},
|
||||
),
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
destination_hash=st.text(min_size=0, max_size=200),
|
||||
file_path=st.text(min_size=0, max_size=2000),
|
||||
)
|
||||
def test_nomadnet_file_download_fuzzing(mock_app, destination_hash, file_path):
|
||||
"""Fuzz nomadnet.file.download WebSocket handler (path traversal, malformed hash)."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
payload = {
|
||||
"type": "nomadnet.file.download",
|
||||
"nomadnet_file_download": {
|
||||
"destination_hash": destination_hash,
|
||||
"file_path": file_path,
|
||||
},
|
||||
}
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(MagicMock(), payload),
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
destination_hash=st.text(min_size=0, max_size=200),
|
||||
page_path=st.text(min_size=0, max_size=2000),
|
||||
field_data=st.one_of(
|
||||
st.dictionaries(
|
||||
keys=st.text(),
|
||||
values=st.one_of(st.text(), st.integers(), st.booleans(), st.none()),
|
||||
),
|
||||
st.text(min_size=0, max_size=5000),
|
||||
st.lists(st.text(), min_size=0, max_size=20),
|
||||
st.none(),
|
||||
),
|
||||
)
|
||||
def test_nomadnet_page_download_fuzzing(
|
||||
mock_app, destination_hash, page_path, field_data
|
||||
):
|
||||
"""Fuzz nomadnet.page.download WebSocket handler (page_path with backtick, field_data)."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
payload = {
|
||||
"type": "nomadnet.page.download",
|
||||
"nomadnet_page_download": {
|
||||
"destination_hash": destination_hash,
|
||||
"page_path": page_path,
|
||||
"field_data": field_data,
|
||||
},
|
||||
}
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(MagicMock(), payload),
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(archive_id=st.one_of(st.integers(), st.text(), st.floats(), st.none()))
|
||||
def test_get_archived_page_by_id_fuzzing(mock_app, archive_id):
|
||||
"""Fuzz archived page lookup by id (SQL injection, type confusion)."""
|
||||
mock_app.database.misc.get_archived_page_by_id(archive_id)
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
forward_to_hash=st.text(min_size=0, max_size=100),
|
||||
identity_hash=st.one_of(st.text(min_size=0, max_size=100), st.none()),
|
||||
source_filter_hash=st.one_of(st.text(min_size=0, max_size=100), st.none()),
|
||||
is_active=st.one_of(st.booleans(), st.integers(), st.none()),
|
||||
name=st.one_of(st.text(min_size=0, max_size=200), st.none()),
|
||||
)
|
||||
def test_lxmf_forwarding_rule_add_fuzzing(
|
||||
mock_app,
|
||||
forward_to_hash,
|
||||
identity_hash,
|
||||
source_filter_hash,
|
||||
is_active,
|
||||
name,
|
||||
):
|
||||
"""Fuzz lxmf.forwarding.rule.add WebSocket handler and create_forwarding_rule."""
|
||||
import asyncio
|
||||
|
||||
rule = {"forward_to_hash": forward_to_hash}
|
||||
if identity_hash is not None:
|
||||
rule["identity_hash"] = identity_hash
|
||||
if source_filter_hash is not None:
|
||||
rule["source_filter_hash"] = source_filter_hash
|
||||
if is_active is not None:
|
||||
rule["is_active"] = is_active
|
||||
if name is not None:
|
||||
rule["name"] = name
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(
|
||||
MagicMock(),
|
||||
{"type": "lxmf.forwarding.rule.add", "rule": rule},
|
||||
),
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
rule_id=st.one_of(st.integers(), st.text(min_size=0, max_size=50), st.none()),
|
||||
)
|
||||
def test_lxmf_forwarding_rule_delete_fuzzing(mock_app, rule_id):
|
||||
"""Fuzz lxmf.forwarding.rule.delete WebSocket handler."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(
|
||||
MagicMock(),
|
||||
{"type": "lxmf.forwarding.rule.delete", "id": rule_id},
|
||||
),
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
rule_id=st.one_of(st.integers(), st.text(min_size=0, max_size=50), st.none()),
|
||||
)
|
||||
def test_lxmf_forwarding_rule_toggle_fuzzing(mock_app, rule_id):
|
||||
"""Fuzz lxmf.forwarding.rule.toggle WebSocket handler."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(
|
||||
MagicMock(),
|
||||
{"type": "lxmf.forwarding.rule.toggle", "id": rule_id},
|
||||
),
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
action=st.text(min_size=0, max_size=200),
|
||||
keys=st.one_of(
|
||||
st.lists(st.text(), min_size=0, max_size=20),
|
||||
st.text(min_size=0, max_size=500),
|
||||
st.integers(),
|
||||
),
|
||||
)
|
||||
def test_keyboard_shortcuts_set_fuzzing(mock_app, action, keys):
|
||||
"""Fuzz keyboard_shortcuts.set WebSocket handler (action and keys)."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
mock_app.on_websocket_data_received(
|
||||
MagicMock(),
|
||||
{"type": "keyboard_shortcuts.set", "action": action, "keys": keys},
|
||||
),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(message_hash=st.text(min_size=0, max_size=200))
|
||||
def test_messages_get_by_hash_fuzzing(mock_app, message_hash):
|
||||
"""Fuzz message lookup by hash (SQL, type confusion)."""
|
||||
mock_app.database.messages.get_lxmf_message_by_hash(message_hash)
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(message_hash=st.text(min_size=0, max_size=200))
|
||||
def test_messages_delete_by_hash_fuzzing(mock_app, message_hash):
|
||||
"""Fuzz single message delete by hash."""
|
||||
try:
|
||||
mock_app.database.messages.delete_lxmf_message_by_hash(message_hash)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(message_hashes=st.lists(st.text(min_size=0, max_size=100), min_size=0, max_size=50))
|
||||
def test_messages_delete_by_hashes_fuzzing(mock_app, message_hashes):
|
||||
"""Fuzz bulk message delete by hashes."""
|
||||
try:
|
||||
mock_app.database.messages.delete_lxmf_messages_by_hashes(message_hashes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||
@given(
|
||||
table_name=st.text(min_size=0, max_size=100),
|
||||
|
||||
@@ -160,7 +160,11 @@ def test_database_integrity_recovery(mock_rns, temp_dir):
|
||||
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
||||
patch(
|
||||
"meshchatx.src.backend.identity_context.IntegrityManager",
|
||||
) as mock_im_class,
|
||||
):
|
||||
mock_im_class.return_value.check_integrity.return_value = (True, [])
|
||||
mock_db_instance = mock_db_class.return_value
|
||||
# Fail the first initialize call
|
||||
mock_db_instance.initialize.side_effect = [
|
||||
@@ -228,7 +232,64 @@ def test_identity_loading_fallback(mock_rns, temp_dir):
|
||||
mock_gen_id.get_private_key.assert_called()
|
||||
|
||||
|
||||
# 4. Test flags/envs
|
||||
# 4. Database health issues set on setup and exposed to app
|
||||
def test_database_health_issues_set_on_setup(mock_rns, temp_dir):
|
||||
with (
|
||||
patch("meshchatx.src.backend.identity_context.Database") as mock_db_class,
|
||||
patch(
|
||||
"meshchatx.src.backend.identity_context.ConfigManager",
|
||||
) as mock_config_class,
|
||||
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
|
||||
patch("meshchatx.src.backend.identity_context.MapManager"),
|
||||
patch("meshchatx.src.backend.identity_context.DocsManager"),
|
||||
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
|
||||
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
||||
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
||||
patch(
|
||||
"meshchatx.src.backend.identity_context.IntegrityManager",
|
||||
) as mock_int_class,
|
||||
patch("aiohttp.web.run_app"),
|
||||
):
|
||||
mock_int_class.return_value.check_integrity.return_value = (True, [])
|
||||
mock_db_instance = mock_db_class.return_value
|
||||
mock_db_instance.check_db_health_at_open.return_value = [
|
||||
"Database content anomaly: test."
|
||||
]
|
||||
mock_config = mock_config_class.return_value
|
||||
mock_config.auth_session_secret.get.return_value = base64.urlsafe_b64encode(
|
||||
secrets.token_bytes(32),
|
||||
).decode()
|
||||
mock_config.display_name.get.return_value = "Test"
|
||||
mock_config.lxmf_propagation_node_stamp_cost.get.return_value = 0
|
||||
mock_config.lxmf_delivery_transfer_limit_in_bytes.get.return_value = 1000000
|
||||
mock_config.lxmf_inbound_stamp_cost.get.return_value = 0
|
||||
mock_config.lxmf_preferred_propagation_node_destination_hash.get.return_value = None
|
||||
mock_config.lxmf_local_propagation_node_enabled.get.return_value = False
|
||||
mock_config.libretranslate_url.get.return_value = "http://localhost:5000"
|
||||
mock_config.translator_enabled.get.return_value = False
|
||||
mock_config.initial_docs_download_attempted.get.return_value = True
|
||||
|
||||
app = ReticulumMeshChat(
|
||||
identity=mock_rns["id_instance"],
|
||||
storage_dir=temp_dir,
|
||||
reticulum_config_dir=temp_dir,
|
||||
)
|
||||
app.run(host="127.0.0.1", port=8000, launch_browser=False, enable_https=False)
|
||||
assert getattr(app, "database_health_issues", []) == [
|
||||
"Database content anomaly: test."
|
||||
]
|
||||
app.teardown_identity()
|
||||
|
||||
|
||||
# 5. Test flags/envs
|
||||
def test_cli_flags_and_envs(mock_rns, temp_dir):
|
||||
with (
|
||||
patch("meshchatx.meshchat.ReticulumMeshChat") as mock_app_class,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import InterfacesPage from "../../meshchatx/src/frontend/components/interfaces/InterfacesPage.vue";
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
|
||||
default: {
|
||||
config: { theme: "light" },
|
||||
hasPendingInterfaceChanges: false,
|
||||
modifiedInterfaceNames: new Set(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
|
||||
default: {
|
||||
formatBytes: (b) => `${b} B`,
|
||||
isInterfaceEnabled: () => true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
|
||||
default: {
|
||||
relaunch: vi.fn(),
|
||||
isElectron: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const mockAxios = {
|
||||
get: vi.fn((url) => {
|
||||
if (url.includes("/api/v1/reticulum/interfaces")) {
|
||||
return Promise.resolve({ data: { interfaces: {} } });
|
||||
}
|
||||
if (url.includes("/api/v1/app/info")) {
|
||||
return Promise.resolve({ data: { app_info: { is_reticulum_running: true } } });
|
||||
}
|
||||
if (url.includes("/api/v1/interface-stats")) {
|
||||
return Promise.resolve({ data: { interface_stats: { interfaces: [] } } });
|
||||
}
|
||||
if (url.includes("/api/v1/reticulum/discovery")) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
discovery: {
|
||||
discover_interfaces: true,
|
||||
interface_discovery_whitelist: "",
|
||||
interface_discovery_blacklist: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/v1/reticulum/discovered-interfaces")) {
|
||||
return Promise.resolve({ data: { interfaces: [], active: [] } });
|
||||
}
|
||||
return Promise.resolve({ data: {} });
|
||||
}),
|
||||
post: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
patch: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
};
|
||||
window.axios = mockAxios;
|
||||
|
||||
describe("InterfacesPage discovery actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows discovery action menu and allows announce", async () => {
|
||||
const wrapper = mount(InterfacesPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: true,
|
||||
MaterialDesignIcon: true,
|
||||
Toggle: true,
|
||||
ImportInterfacesModal: true,
|
||||
Interface: true,
|
||||
},
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$router: { push: vi.fn() },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.setData({
|
||||
discoveredInterfaces: [
|
||||
{
|
||||
name: "Peer Node",
|
||||
type: "TCPClientInterface",
|
||||
reachable_on: "10.0.0.8",
|
||||
port: 4242,
|
||||
discovery_hash: "hash-1",
|
||||
},
|
||||
],
|
||||
discoveryConfig: {
|
||||
discover_interfaces: true,
|
||||
interface_discovery_sources: "",
|
||||
interface_discovery_whitelist: "",
|
||||
interface_discovery_blacklist: "old-entry",
|
||||
required_discovery_value: null,
|
||||
autoconnect_discovered_interfaces: 0,
|
||||
network_identity: "",
|
||||
},
|
||||
});
|
||||
|
||||
const actionButton = wrapper.find('button[title="Discovery actions"]');
|
||||
expect(actionButton.exists()).toBe(true);
|
||||
await actionButton.trigger("click");
|
||||
expect(wrapper.text()).toContain("Allow this announce");
|
||||
|
||||
const allowButton = wrapper.findAll("button").find((btn) => btn.text().includes("Allow this announce"));
|
||||
expect(allowButton).toBeTruthy();
|
||||
await allowButton.trigger("click");
|
||||
|
||||
expect(mockAxios.patch).toHaveBeenCalledWith("/api/v1/reticulum/discovery", {
|
||||
interface_discovery_whitelist: "10.0.0.8:4242",
|
||||
interface_discovery_blacklist: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("blacklists announce and removes same token from whitelist", async () => {
|
||||
const wrapper = mount(InterfacesPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: true,
|
||||
MaterialDesignIcon: true,
|
||||
Toggle: true,
|
||||
ImportInterfacesModal: true,
|
||||
Interface: true,
|
||||
},
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$router: { push: vi.fn() },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.setData({
|
||||
discoveredInterfaces: [
|
||||
{
|
||||
name: "Peer Node",
|
||||
type: "TCPClientInterface",
|
||||
reachable_on: "10.0.0.8",
|
||||
port: 4242,
|
||||
discovery_hash: "hash-2",
|
||||
},
|
||||
],
|
||||
discoveryConfig: {
|
||||
discover_interfaces: true,
|
||||
interface_discovery_sources: "",
|
||||
interface_discovery_whitelist: "10.0.0.8:4242,other",
|
||||
interface_discovery_blacklist: "",
|
||||
required_discovery_value: null,
|
||||
autoconnect_discovered_interfaces: 0,
|
||||
network_identity: "",
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find('button[title="Discovery actions"]').trigger("click");
|
||||
const blockButton = wrapper.findAll("button").find((btn) => btn.text().includes("Blacklist this announce"));
|
||||
expect(blockButton).toBeTruthy();
|
||||
await blockButton.trigger("click");
|
||||
|
||||
expect(mockAxios.patch).toHaveBeenCalledWith("/api/v1/reticulum/discovery", {
|
||||
interface_discovery_whitelist: null,
|
||||
interface_discovery_blacklist: "10.0.0.8:4242",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user