mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
336 lines
10 KiB
Python
336 lines
10 KiB
Python
# SPDX-License-Identifier: 0BSD
|
|
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from meshchatx.src.backend.database import Database, DatabaseRestoreError
|
|
from meshchatx.src.backend.database.provider import DatabaseProvider
|
|
from meshchatx.src.backend.database.schema import DatabaseMigrationError, DatabaseSchema
|
|
from meshchatx.src.backend.integrity_manager import IntegrityManager
|
|
from meshchatx.src.backend.storage_lock import StorageLock, StorageLockError
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_dir():
|
|
path = tempfile.mkdtemp()
|
|
yield path
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_provider():
|
|
DatabaseProvider._instance = None
|
|
yield
|
|
if DatabaseProvider._instance is not None:
|
|
DatabaseProvider._instance.close_all()
|
|
DatabaseProvider._instance = None
|
|
|
|
|
|
def test_provider_path_switch_does_not_deadlock(temp_dir):
|
|
db_path_a = os.path.join(temp_dir, "a.db")
|
|
db_path_b = os.path.join(temp_dir, "b.db")
|
|
DatabaseProvider.get_instance(db_path_a)
|
|
provider_b = DatabaseProvider.get_instance(db_path_b)
|
|
assert provider_b.db_path == db_path_b
|
|
DatabaseProvider._instance.close_all()
|
|
|
|
|
|
def test_provider_path_switch_calls_close_all(temp_dir):
|
|
db_path_a = os.path.join(temp_dir, "a.db")
|
|
db_path_b = os.path.join(temp_dir, "b.db")
|
|
provider_a = DatabaseProvider.get_instance(db_path_a)
|
|
with patch.object(provider_a, "close_all") as mock_close:
|
|
DatabaseProvider.get_instance(db_path_b)
|
|
mock_close.assert_called_once()
|
|
DatabaseProvider._instance.close_all()
|
|
|
|
|
|
def test_restore_invokes_close_all_before_replace(temp_dir):
|
|
db_path = os.path.join(temp_dir, "live.db")
|
|
db = Database(db_path)
|
|
db.initialize()
|
|
db.execute_sql("INSERT INTO config (key, value) VALUES (?, ?)", ("k", "v1"))
|
|
backup_path = os.path.join(temp_dir, "backup.zip")
|
|
db.backup_database(temp_dir, backup_path=backup_path)
|
|
with patch.object(
|
|
db.provider,
|
|
"close_all",
|
|
wraps=db.provider.close_all,
|
|
) as mock_close:
|
|
db.restore_database(backup_path)
|
|
assert mock_close.call_count >= 1
|
|
row = db.provider.fetchone("SELECT value FROM config WHERE key = ?", ("k",))
|
|
assert row["value"] == "v1"
|
|
db.close_all()
|
|
|
|
|
|
def test_migration_failure_does_not_bump_version(temp_dir):
|
|
db_path = os.path.join(temp_dir, "broken_migrate.db")
|
|
provider = DatabaseProvider.get_instance(db_path)
|
|
schema = DatabaseSchema(provider)
|
|
provider.execute(
|
|
"""
|
|
CREATE TABLE config (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
key TEXT UNIQUE,
|
|
value TEXT
|
|
)
|
|
""",
|
|
)
|
|
provider.execute(
|
|
"INSERT INTO config (key, value) VALUES (?, ?)",
|
|
("database_version", "47"),
|
|
)
|
|
|
|
def fail_run(_current_version, _target_version):
|
|
schema._migration_errors.append("simulated migration failure")
|
|
|
|
schema._run_migrations = fail_run
|
|
with pytest.raises(DatabaseMigrationError):
|
|
schema.migrate(47)
|
|
|
|
row = provider.fetchone(
|
|
"SELECT value FROM config WHERE key = 'database_version'",
|
|
)
|
|
assert int(row["value"]) == 47
|
|
provider.close_all()
|
|
|
|
|
|
def test_integrity_allows_hash_change_when_sqlite_ok(temp_dir):
|
|
db_path = os.path.join(temp_dir, "database.db")
|
|
conn = sqlite3.connect(db_path)
|
|
conn.execute("CREATE TABLE data (id INTEGER PRIMARY KEY, val TEXT)")
|
|
conn.execute("INSERT INTO data (val) VALUES ('x')")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
manager = IntegrityManager(temp_dir, db_path)
|
|
manager.save_manifest()
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
conn.execute("INSERT INTO data (val) VALUES ('y')")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
is_ok, issues = manager.check_integrity()
|
|
assert is_ok, issues
|
|
|
|
|
|
def test_integrity_flags_structural_damage(temp_dir):
|
|
db_path = os.path.join(temp_dir, "database.db")
|
|
conn = sqlite3.connect(db_path)
|
|
conn.execute("CREATE TABLE data (id INTEGER PRIMARY KEY)")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
manager = IntegrityManager(temp_dir, db_path)
|
|
manager.save_manifest()
|
|
|
|
with open(db_path, "r+b") as handle:
|
|
handle.seek(0)
|
|
handle.write(b"NOTASQLITEFILE")
|
|
|
|
is_ok, issues = manager.check_integrity()
|
|
assert not is_ok
|
|
assert any("Database structural issue" in i for i in issues)
|
|
|
|
|
|
def test_storage_lock_rejects_second_instance(temp_dir):
|
|
lock_a = StorageLock(temp_dir)
|
|
lock_a.acquire()
|
|
lock_b = StorageLock(temp_dir)
|
|
with pytest.raises(StorageLockError):
|
|
lock_b.acquire()
|
|
lock_a.release()
|
|
|
|
|
|
def test_storage_lock_soft_fallback_when_flock_unsupported(temp_dir, monkeypatch):
|
|
import errno
|
|
import fcntl
|
|
|
|
real_flock = fcntl.flock
|
|
|
|
def flock_enosys(fd, op):
|
|
if op & fcntl.LOCK_NB:
|
|
raise OSError(errno.ENOSYS, "Function not implemented")
|
|
return real_flock(fd, op)
|
|
|
|
monkeypatch.setattr(fcntl, "flock", flock_enosys)
|
|
|
|
lock_a = StorageLock(temp_dir)
|
|
lock_a.acquire()
|
|
assert lock_a._soft is True
|
|
# Same-process re-acquire must still fail under soft lock.
|
|
lock_b = StorageLock(temp_dir)
|
|
with pytest.raises(StorageLockError):
|
|
lock_b.acquire()
|
|
lock_a.release()
|
|
|
|
lock_c = StorageLock(temp_dir)
|
|
lock_c.acquire()
|
|
assert lock_c._soft is True
|
|
lock_c.release()
|
|
|
|
|
|
def test_storage_lock_soft_allows_dead_pid(temp_dir, monkeypatch):
|
|
import errno
|
|
import fcntl
|
|
|
|
monkeypatch.setattr(
|
|
fcntl,
|
|
"flock",
|
|
lambda *_a, **_k: (_ for _ in ()).throw(
|
|
OSError(errno.ENOSYS, "Function not implemented"),
|
|
),
|
|
)
|
|
lock_path = os.path.join(temp_dir, ".meshchatx.lock")
|
|
with open(lock_path, "wb") as handle:
|
|
handle.write(b"999999999")
|
|
|
|
lock = StorageLock(temp_dir)
|
|
lock.acquire()
|
|
assert lock._soft is True
|
|
lock.release()
|
|
|
|
|
|
def test_storage_lock_android_soft_ignores_foreign_pid(temp_dir, monkeypatch):
|
|
import errno
|
|
import fcntl
|
|
|
|
from meshchatx.src.backend import storage_lock as storage_lock_mod
|
|
|
|
monkeypatch.setattr(
|
|
fcntl,
|
|
"flock",
|
|
lambda *_a, **_k: (_ for _ in ()).throw(
|
|
OSError(errno.ENOSYS, "Function not implemented"),
|
|
),
|
|
)
|
|
monkeypatch.setenv("ANDROID_ROOT", "/system")
|
|
monkeypatch.setattr(storage_lock_mod.os, "kill", lambda *_a, **_k: None)
|
|
|
|
lock_path = os.path.join(temp_dir, ".meshchatx.lock")
|
|
with open(lock_path, "wb") as handle:
|
|
handle.write(b"1")
|
|
|
|
lock = StorageLock(temp_dir)
|
|
lock.acquire()
|
|
assert lock._soft is True
|
|
lock.release()
|
|
|
|
|
|
def test_restore_rejects_non_sqlite_backup(temp_dir):
|
|
db_path = os.path.join(temp_dir, "main.db")
|
|
db = Database(db_path)
|
|
db.initialize()
|
|
|
|
bad_backup = os.path.join(temp_dir, "bad.db")
|
|
with open(bad_backup, "wb") as handle:
|
|
handle.write(b"not a sqlite database")
|
|
|
|
with pytest.raises(DatabaseRestoreError, match="not a valid SQLite"):
|
|
db.restore_database(bad_backup)
|
|
db.close_all()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
|
|
def test_safe_zip_extract_rejects_symlink_jail_escape(temp_dir):
|
|
"""Restore must not follow identity-dir symlinks outside the jail."""
|
|
import zipfile
|
|
|
|
identity_dir = os.path.join(temp_dir, "identity")
|
|
outside = os.path.join(temp_dir, "OUTSIDE")
|
|
os.makedirs(identity_dir)
|
|
os.makedirs(outside)
|
|
link = os.path.join(identity_dir, "plugins")
|
|
os.symlink(outside, link)
|
|
|
|
zip_path = os.path.join(temp_dir, "evil.zip")
|
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
zf.writestr("plugins/stolen_write.txt", b"pwned")
|
|
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
with pytest.raises(DatabaseRestoreError, match="Unsafe zip entry"):
|
|
Database._safe_zip_extract_member(
|
|
zf, "plugins/stolen_write.txt", identity_dir
|
|
)
|
|
|
|
assert not os.path.exists(os.path.join(outside, "stolen_write.txt"))
|
|
|
|
|
|
def test_looks_like_sqlite_header():
|
|
path = tempfile.NamedTemporaryFile(delete=False).name
|
|
try:
|
|
conn = sqlite3.connect(path)
|
|
conn.execute("CREATE TABLE t (id INTEGER)")
|
|
conn.close()
|
|
assert Database._looks_like_sqlite(path)
|
|
with open(path, "r+b") as handle:
|
|
handle.seek(0)
|
|
handle.write(b"garbage")
|
|
assert not Database._looks_like_sqlite(path)
|
|
finally:
|
|
os.remove(path)
|
|
|
|
|
|
class TestRestoreDatabaseMethod(unittest.TestCase):
|
|
def setUp(self):
|
|
self.test_dir = tempfile.mkdtemp()
|
|
self.db_path = os.path.join(self.test_dir, "test.db")
|
|
|
|
def tearDown(self):
|
|
if DatabaseProvider._instance is not None:
|
|
DatabaseProvider._instance.close_all()
|
|
DatabaseProvider._instance = None
|
|
shutil.rmtree(self.test_dir)
|
|
|
|
def test_checkpoint_and_close_uses_close_all(self):
|
|
db = Database(self.db_path)
|
|
db.initialize()
|
|
with patch.object(db.provider, "close_all") as mock_close:
|
|
db._checkpoint_and_close()
|
|
mock_close.assert_called_once()
|
|
|
|
|
|
class TestMeshchatRestoreFlow(unittest.TestCase):
|
|
@patch("meshchatx.meshchat.ReticulumMeshChat._schedule_process_restart")
|
|
def test_restore_database_prepares_and_schedules_restart(self, mock_restart):
|
|
from meshchatx.meshchat import ReticulumMeshChat
|
|
|
|
temp = tempfile.mkdtemp()
|
|
try:
|
|
db_path = os.path.join(temp, "identities", "abc", "database.db")
|
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
app = object.__new__(ReticulumMeshChat)
|
|
app.contexts = {}
|
|
app.current_context = None
|
|
app.storage_dir = temp
|
|
app.identity_file_path = None
|
|
app._teardown_all_contexts_for_reload = unittest.mock.Mock()
|
|
|
|
db = Database(db_path)
|
|
db.initialize()
|
|
backup_path = os.path.join(temp, "b.zip")
|
|
db.backup_database(temp, backup_path=backup_path)
|
|
db.close_all()
|
|
|
|
with patch.object(
|
|
ReticulumMeshChat,
|
|
"prepare_for_database_restore",
|
|
return_value=db_path,
|
|
):
|
|
result = ReticulumMeshChat.restore_database(
|
|
app,
|
|
backup_path,
|
|
relaunch=True,
|
|
)
|
|
assert result["restored_from"] == backup_path
|
|
mock_restart.assert_called_once()
|
|
finally:
|
|
shutil.rmtree(temp)
|