diff --git a/src/nostr/client.py b/src/nostr/client.py index 420f574..c04f89a 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -594,6 +594,9 @@ class NostrClient: ) timeout = timedelta(seconds=10) events = (await self.client.fetch_events(f, timeout)).to_vec() + events.sort( + key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0)) + ) deltas: list[bytes] = [] for ev in events: deltas.append(base64.b64decode(ev.content().encode("utf-8"))) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index d1cf5f3..bd844f6 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1175,17 +1175,26 @@ class PasswordManager: return manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) - if manifest.delta_since: - version = int(manifest.delta_since) - deltas = await self.nostr_client.fetch_deltas_since(version) - if deltas: - encrypted = deltas[-1] current = self.vault.get_encrypted_index() + updated = False if current != encrypted: if self.vault.decrypt_and_save_index_from_nostr( encrypted, strict=False ): - logger.info("Local database synchronized from Nostr.") + updated = True + current = encrypted + if manifest.delta_since: + version = int(manifest.delta_since) + deltas = await self.nostr_client.fetch_deltas_since(version) + for delta in deltas: + if current != delta: + if self.vault.decrypt_and_save_index_from_nostr( + delta, strict=False + ): + updated = True + current = delta + if updated: + logger.info("Local database synchronized from Nostr.") except Exception as e: logger.warning( "Unable to sync index from Nostr relays %s: %s", @@ -1304,17 +1313,22 @@ class PasswordManager: if result: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) - if manifest.delta_since: - version = int(manifest.delta_since) - deltas = await self.nostr_client.fetch_deltas_since(version) - if deltas: - encrypted = deltas[-1] success = self.vault.decrypt_and_save_index_from_nostr( encrypted, strict=False ) if success: - logger.info("Initialized local database from Nostr.") have_data = True + current = encrypted + if manifest.delta_since: + version = int(manifest.delta_since) + deltas = await self.nostr_client.fetch_deltas_since(version) + for delta in deltas: + if current != delta: + if self.vault.decrypt_and_save_index_from_nostr( + delta, strict=False + ): + current = delta + logger.info("Initialized local database from Nostr.") except Exception as e: # pragma: no cover - network errors logger.warning(f"Unable to sync index from Nostr: {e}") finally: diff --git a/src/tests/test_multiple_deltas_sync.py b/src/tests/test_multiple_deltas_sync.py new file mode 100644 index 0000000..3b2b2f7 --- /dev/null +++ b/src/tests/test_multiple_deltas_sync.py @@ -0,0 +1,96 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.is_dirty = False + return pm + + +def test_sync_applies_multiple_deltas(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + # Initial snapshot from manager A + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + + # Manager B downloads snapshot + assert pm_b.attempt_initial_sync() is True + + # Two deltas published sequentially + pm_a.entry_manager.add_entry("site2", 12) + delta1 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta1, manifest_id)) + + pm_a.entry_manager.add_entry("site3", 12) + delta2 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta2, manifest_id)) + + # B syncs and should apply both deltas + pm_b.sync_index_from_nostr() + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2", "site3"] + + +def test_initial_sync_applies_multiple_deltas(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + + pm_a.entry_manager.add_entry("site2", 12) + delta1 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta1, manifest_id)) + + pm_a.entry_manager.add_entry("site3", 12) + delta2 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta2, manifest_id)) + + # Initial sync after both deltas published + assert pm_b.attempt_initial_sync() is True + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2", "site3"]