#!/usr/bin/env python3 # Libervia: an XMPP client # Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # The backup engine is asyncio based, but the test suite runs with the Twisted # loop: the async flows are bridged with the "aio" decorator (as in # memory/sqla.py). import json from pathlib import Path from lxml import etree from pytest import fixture, raises from pytest_twisted import ensureDeferred as ed from sqlalchemy import select, text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from libervia.backend.memory.sqla_mapping import ( Base, ParamGen, ParamInd, PrivateGen, PrivateGenBin, PrivateInd, Profile, ) from libervia.backend.tools.backup import ( FORMAT, FORMAT_VERSION, PIE_FILE_TPL, Backup, BackupError, ProfileData, ProfileHandler, ProfileMeta, Report, Staging, get_db_driver, sqlite_db_config, ) from libervia.backend.tools.backup.core import ( DB_FILE, NS_PARAMS, NS_PRIVATE, NS_PRIVATE_BIN, ) from libervia.backend.tools.backup.xep_0227 import ( NS_PUBSUB, NS_ROSTER, parse_pie, sections_by_ns, ) from libervia.backend.tools.utils import aio PEP_ITEM_DATA = ( 'Romy' ) ROSTER_ITEM = ( "" ) async def build_db(db_path: Path, data: bool = True) -> None: """Create a small database (one profile, optionally with data rows)""" db_path.unlink(missing_ok=True) engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) session_factory = async_sessionmaker(engine, expire_on_commit=False) async with session_factory() as session: session.add(Profile(id=1, name="test")) if data: session.add( ParamGen(category="Connection", name="Server", value="example.org") ) session.add( ParamInd( category="Connection", name="JabberID", profile_id=1, value="user@example.org", ) ) session.add(PrivateGen(namespace="urn:xmpp:example", key="foo", value="bar")) session.add( PrivateInd( namespace="urn:xmpp:example", key="baz", value="qux", profile_id=1 ) ) session.add( PrivateGenBin(namespace="urn:xmpp:example", key="json", value={"a": 1}) ) await session.commit() await engine.dispose() async def add_cache_data(db_path: Path) -> None: """Add a PEP node/item and a roster cache row to an existing database The pubsub rows are inserted with plain SQL (the app-specific column types don't need to be exercised here). """ engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}") session_factory = async_sessionmaker(engine, expire_on_commit=False) try: async with engine.begin() as conn: await conn.execute( text( "INSERT INTO pubsub_nodes (profile_id, service, name, pep, " "subscribed, sync_state_updated, max_items) " "VALUES (1, 'user@example.org', 'http://jabber.org/protocol/nick', " "1, 0, 0.0, 0)" ) ) node_id = ( await conn.execute(text("SELECT id FROM pubsub_nodes")) ).scalar_one() await conn.execute( text( "INSERT INTO pubsub_items (node_id, name, data, created, updated) " "VALUES (:node_id, 'item1', :data, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" ), {"node_id": node_id, "data": PEP_ITEM_DATA}, ) async with session_factory() as session: session.add( PrivateInd( namespace="urn:xmpp:features:rosterver", key="@version@", value="42", profile_id=1, ) ) session.add( PrivateInd( namespace="urn:xmpp:features:rosterver", key="contact@example.org", value=ROSTER_ITEM, profile_id=1, ) ) await session.commit() finally: await engine.dispose() def manifest_bytes(format=FORMAT, format_version=FORMAT_VERSION) -> bytes: """Get the bytes of a minimal valid manifest""" return json.dumps( { "format": format, "format_version": format_version, "app": "libervia", "app_version": "1.0", "date": "2026-01-01T00:00:00Z", } ).encode() @fixture def env(tmp_path): """Two local data directories: "src" and "fresh" (databases are built by the tests)""" src, fresh = tmp_path / "src", tmp_path / "fresh" for d in (src, fresh): d.mkdir() (d / "param").write_text("") return src, fresh def profile_handlers(types: list[str]) -> list[ProfileHandler]: """Profile handlers of the given data types""" return [h for h in Backup.get_handlers(types) if isinstance(h, ProfileHandler)] @fixture def prof_handlers(): """Profile handlers of the "params" and "private" data types""" return profile_handlers(["params", "private"]) class TestBackup: def test_profile_data(self): """The profile JID is parsed, and the profile name is the fallback""" data = ProfileData(name="test", id=1, jid="user@example.org") assert (data.host_jid, data.user_name) == ("example.org", "user") data = ProfileData(name="test", id=1, jid="not a jid") assert data.host_jid is None assert data.user_name == "test" @ed async def test_export_profile(self, env, prof_handlers): """Exporting a profile builds a pie document with its data sections""" src, _ = env @aio async def flow(): await build_db(src / "libervia.db") ctx = await Backup.open_context( sqlite_db_config(src), src / "param", src, readonly=True ) staging = Staging() try: profile = (await Backup.get_profiles(ctx))[0] assert profile.jid == "user@example.org" await Backup.export_profile(ctx, staging, profile, prof_handlers) host_jid, user, sections = parse_pie( staging.path(PIE_FILE_TPL.format(profile.name)).read_bytes() ) assert (host_jid, user) == ("example.org", "user") assert set(sections_by_ns(sections)) == { NS_PARAMS, NS_PRIVATE, NS_PRIVATE_BIN, } finally: await ctx.close() staging.close() await flow() @ed async def test_import_profile(self, env, prof_handlers): """Importing a profile restores its data in another database""" src, fresh = env @aio async def flow(): await build_db(src / "libervia.db") await build_db(fresh / "libervia.db", data=False) ctx = await Backup.open_context( sqlite_db_config(src), src / "param", src, readonly=True ) staging = Staging() try: profile = (await Backup.get_profiles(ctx))[0] await Backup.export_profile(ctx, staging, profile, prof_handlers) await ctx.close() ctx = await Backup.open_context( sqlite_db_config(fresh), fresh / "param", fresh ) report = Report() await Backup.import_profile( ctx, staging, profile.name, prof_handlers, report ) async with ctx.session() as session: server = (await session.execute(select(ParamGen.value))).scalar_one() jid_val = (await session.execute(select(ParamInd.value))).scalar_one() private = ( await session.execute(select(PrivateInd.value)) ).scalar_one() private_bin = ( await session.execute(select(PrivateGenBin.value)) ).scalar_one() await ctx.close() finally: staging.close() assert server == "example.org" assert jid_val == "user@example.org" assert private == "qux" assert private_bin == {"a": 1} await flow() def test_archive_round_trip(self, tmp_path): """The archive can be read back with the same contents and entry modes""" staging = Staging() try: staging.add_bytes("server-data-test.xml", b"") staging.add_bytes("param.xml", b"", mode=0o644) manifest = Backup.build_manifest( Backup.get_app_version(), None, {"test": ProfileMeta(jid="user@example.org", types=["params"])}, staging, ) arc = Backup.make_archive(staging, manifest, tmp_path) finally: staging.close() data = Staging() try: checked = Backup.read_archive(arc, data) assert checked.app == "libervia" assert data.path("server-data-test.xml").read_bytes() == b"" assert data.path("server-data-test.xml").stat().st_mode & 0o777 == 0o600 assert data.path("param.xml").stat().st_mode & 0o777 == 0o644 finally: data.close() def test_tampered_archive(self, tmp_path): """An archive whose content doesn't match the manifest is rejected""" staging = Staging() try: staging.add_bytes("data.bin", b"original data") manifest = Backup.build_manifest(Backup.get_app_version(), None, {}, staging) staging.path("data.bin").write_bytes(b"tampered data") arc = Backup.make_archive(staging, manifest, tmp_path) finally: staging.close() data = Staging() try: with raises(BackupError, match="checksum mismatch"): Backup.read_archive(arc, data) finally: data.close() def test_manifest_validation(self): """The manifest is rejected if it is not from this backup format""" with raises(BackupError, match="unsupported format"): Backup._load_manifest(manifest_bytes(format="other")) with raises(BackupError, match="unsupported format version"): Backup._load_manifest(manifest_bytes(format_version=2)) manifest = Backup._load_manifest(manifest_bytes()) assert manifest.app == "libervia" @ed async def test_db_snapshot_restore(self, env): """The database restore replaces the database, with a pre-restore copy""" src, fresh = env @aio async def flow(): await build_db(src / "libervia.db") await build_db(fresh / "libervia.db", data=False) snapshot = get_db_driver(sqlite_db_config(src)) restore = get_db_driver(sqlite_db_config(fresh)) staging = Staging() try: snapshot.snapshot(staging) assert DB_FILE in staging with raises(BackupError): restore.restore(staging, force=False) restore.restore(staging, force=True) assert len(list(fresh.glob("libervia.db.pre-restore-*"))) == 1 # the restored database has the source data (the fresh one had none) ctx = await Backup.open_context( sqlite_db_config(fresh), fresh / "param", fresh, readonly=True ) async with ctx.session() as session: value = (await session.execute(select(PrivateGen.value))).scalar_one() await ctx.close() assert value == "bar" finally: staging.close() await flow() @ed async def test_pep_round_trip(self, env): """PEP items are exported to pie, and imported back into the pubsub cache""" src, fresh = env @aio async def flow(): await build_db(src / "libervia.db") await add_cache_data(src / "libervia.db") await build_db(fresh / "libervia.db", data=False) handlers = profile_handlers(["pep"]) staging = Staging() ctx = await Backup.open_context( sqlite_db_config(src), src / "param", src, readonly=True ) try: profile = (await Backup.get_profiles(ctx))[0] await Backup.export_profile(ctx, staging, profile, handlers) await ctx.close() _, _, sections = parse_pie( staging.path(PIE_FILE_TPL.format(profile.name)).read_bytes() ) (section,) = sections_by_ns(sections)[NS_PUBSUB] (items,) = section.findall(f"{{{NS_PUBSUB}}}items") assert items.get("service") == "user@example.org" item = items.findall(f"{{{NS_PUBSUB}}}item")[0] assert item.get("id") == "item1" assert item.findtext("{http://jabber.org/protocol/nick}nick") == "Romy" report = Report() ctx = await Backup.open_context( sqlite_db_config(fresh), fresh / "param", fresh ) await Backup.import_profile(ctx, staging, profile.name, handlers, report) async with ctx.session() as session: data = ( await session.execute( text("SELECT data FROM pubsub_items WHERE name='item1'") ) ).scalar_one() await ctx.close() if isinstance(data, str): data = data.encode() stored = etree.fromstring(data) assert stored.findtext("{http://jabber.org/protocol/nick}nick") == "Romy" finally: staging.close() await flow() @ed async def test_roster_export(self, env): """The roster cache (rows in the database) is exported as a pie section""" src, _ = env @aio async def flow(): await build_db(src / "libervia.db") await add_cache_data(src / "libervia.db") ctx = await Backup.open_context( sqlite_db_config(src), src / "param", src, readonly=True ) staging = Staging() try: profile = (await Backup.get_profiles(ctx))[0] await Backup.export_profile( ctx, staging, profile, profile_handlers(["roster"]) ) _, _, sections = parse_pie( staging.path(PIE_FILE_TPL.format(profile.name)).read_bytes() ) (section,) = sections_by_ns(sections)[NS_ROSTER] assert section.get("ver") == "42" item = section.find(f"{{{NS_ROSTER}}}item") assert item.get("jid") == "contact@example.org" finally: await ctx.close() staging.close() await flow() def test_verify_archive(self, tmp_path): """verify_archive reports the checksum status of each entry""" staging = Staging() try: staging.add_bytes("data.bin", b"original data") manifest = Backup.build_manifest(Backup.get_app_version(), None, {}, staging) arc = Backup.make_archive(staging, manifest, tmp_path) # the manifest hashes the original content: the tampered archive # must be reported as such staging.path("data.bin").write_bytes(b"tampered data") arc_t = Backup.make_archive(staging, manifest, tmp_path) finally: staging.close() _, results = Backup.verify_archive(arc) assert results["data.bin"]["checksum"] == "ok" _, results = Backup.verify_archive(arc_t) assert results["data.bin"]["checksum"] == "mismatch"