diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py
new file mode 100644
index 00000000..ac443c30
--- /dev/null
+++ b/tests/unit/test_backup.py
@@ -0,0 +1,458 @@
+#!/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"