From 49a5329bf633380f57cde134257281fd22d34dd4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:04:32 -0400 Subject: [PATCH 1/5] Add event_id tracking for Nostr chunks --- src/nostr/backup_models.py | 1 + src/nostr/client.py | 22 ++++++++++++++-------- src/tests/helpers.py | 9 +++++++++ src/tests/test_nostr_contract.py | 2 +- src/tests/test_nostr_dummy_client.py | 9 +++++++++ src/tests/test_nostr_snapshot.py | 5 +++++ 6 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/nostr/backup_models.py b/src/nostr/backup_models.py index 98210b9..b3c7e8c 100644 --- a/src/nostr/backup_models.py +++ b/src/nostr/backup_models.py @@ -14,6 +14,7 @@ class ChunkMeta: id: str size: int hash: str + event_id: Optional[str] = None @dataclass diff --git a/src/nostr/client.py b/src/nostr/client.py index d0f0af3..e59fd28 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -78,6 +78,7 @@ def prepare_snapshot( id=f"seedpass-chunk-{i:04d}", size=len(chunk), hash=hashlib.sha256(chunk).hexdigest(), + event_id=None, ) ) @@ -372,7 +373,13 @@ class NostrClient: [Tag.identifier(meta.id)] ) event = builder.build(self.keys.public_key()).sign_with_keys(self.keys) - await self.client.send_event(event) + result = await self.client.send_event(event) + try: + meta.event_id = ( + result.id.to_hex() if hasattr(result, "id") else str(result) + ) + except Exception: + meta.event_id = None manifest_json = json.dumps( { @@ -428,13 +435,12 @@ class NostrClient: chunks: list[bytes] = [] for meta in manifest.chunks: - cf = ( - Filter() - .author(pubkey) - .kind(Kind(KIND_SNAPSHOT_CHUNK)) - .identifier(meta.id) - .limit(1) - ) + cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK)) + if meta.event_id: + cf = cf.id(EventId.parse(meta.event_id)) + else: + cf = cf.identifier(meta.id) + cf = cf.limit(1) cev = (await self.client.fetch_events(cf, timeout)).to_vec() if not cev: raise ValueError(f"Missing chunk {meta.id}") diff --git a/src/tests/helpers.py b/src/tests/helpers.py index ab6f0c4..8157bc3 100644 --- a/src/tests/helpers.py +++ b/src/tests/helpers.py @@ -108,6 +108,7 @@ class DummyFilter: self.ids: list[str] = [] self.limit_val: int | None = None self.since_val: int | None = None + self.id_called: bool = False def author(self, _pk): return self @@ -125,6 +126,11 @@ class DummyFilter: self.ids.append(ident) return self + def id(self, ident: str): + self.id_called = True + self.ids.append(ident) + return self + def limit(self, val: int): self.limit_val = val return self @@ -167,6 +173,7 @@ class DummyRelayClient: self.manifests: list[DummyEvent] = [] self.chunks: dict[str, DummyEvent] = {} self.deltas: list[DummyEvent] = [] + self.filters: list[DummyFilter] = [] async def add_relays(self, _relays): pass @@ -195,6 +202,7 @@ class DummyRelayClient: elif event.kind == KIND_SNAPSHOT_CHUNK: ident = event.tags[0] if event.tags else str(self.counter) self.chunks[ident] = event + self.chunks[eid] = event elif event.kind == KIND_DELTA: if not hasattr(event, "created_at"): self.ts_counter += 1 @@ -203,6 +211,7 @@ class DummyRelayClient: return DummySendResult(eid) async def fetch_events(self, f, _timeout): + self.filters.append(f) kind = getattr(f, "kind_val", None) limit = getattr(f, "limit_val", None) identifier = f.ids[0] if getattr(f, "ids", None) else None diff --git a/src/tests/test_nostr_contract.py b/src/tests/test_nostr_contract.py index 34ce289..5501be0 100644 --- a/src/tests/test_nostr_contract.py +++ b/src/tests/test_nostr_contract.py @@ -39,7 +39,7 @@ class MockClient: class FakeId: def to_hex(self_inner): - return "abcd" + return "a" * 64 class FakeOutput: def __init__(self): diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 5cdfddb..fd91682 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -7,6 +7,7 @@ from password_manager.entry_management import EntryManager from password_manager.backup import BackupManager from password_manager.config_manager import ConfigManager from nostr.client import prepare_snapshot +from nostr.backup_models import KIND_SNAPSHOT_CHUNK def test_manifest_generation(tmp_path): @@ -35,10 +36,18 @@ def test_retrieve_multi_chunk_snapshot(dummy_nostr_client): data = os.urandom(120000) manifest, _ = asyncio.run(client.publish_snapshot(data, limit=50000)) assert len(manifest.chunks) > 1 + for meta in manifest.chunks: + assert meta.event_id fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot()) assert len(chunk_bytes) == len(manifest.chunks) + assert [c.event_id for c in fetched_manifest.chunks] == [ + c.event_id for c in manifest.chunks + ] joined = b"".join(chunk_bytes) assert gzip.decompress(joined) == data + for f in relay.filters: + if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK: + assert f.id_called def test_publish_and_fetch_deltas(dummy_nostr_client): diff --git a/src/tests/test_nostr_snapshot.py b/src/tests/test_nostr_snapshot.py index 32fcbdb..b466f46 100644 --- a/src/tests/test_nostr_snapshot.py +++ b/src/tests/test_nostr_snapshot.py @@ -68,6 +68,8 @@ class DummyClient: def test_fetch_latest_snapshot(): data = b"seedpass" * 1000 manifest, chunks = prepare_snapshot(data, 50000) + for i, m in enumerate(manifest.chunks): + m.event_id = f"{i:064x}" manifest_json = json.dumps( { "ver": manifest.ver, @@ -98,3 +100,6 @@ def test_fetch_latest_snapshot(): assert manifest == result_manifest assert result_chunks == chunks + assert [c.event_id for c in manifest.chunks] == [ + c.event_id for c in result_manifest.chunks + ] From 8579cf7f3da5de9246fbef1e5104d62a7f27b4fa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:43:04 -0400 Subject: [PATCH 2/5] Add snapshot retrieval retries and fallback --- src/nostr/client.py | 95 ++++++++++++++++++---------- src/tests/test_nostr_dummy_client.py | 40 ++++++++++++ 2 files changed, 100 insertions(+), 35 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index e59fd28..ea2d817 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -407,6 +407,60 @@ class NostrClient: logger.info("publish_snapshot completed in %.2f seconds", duration) return manifest, manifest_id + async def _fetch_chunks_with_retry( + self, manifest_event + ) -> tuple[Manifest, list[bytes]] | None: + """Retrieve all chunks referenced by ``manifest_event`` with retries.""" + + pubkey = self.keys.public_key() + timeout = timedelta(seconds=10) + + try: + data = json.loads(manifest_event.content()) + manifest = Manifest( + ver=data["ver"], + algo=data["algo"], + chunks=[ChunkMeta(**c) for c in data["chunks"]], + delta_since=( + int(data["delta_since"]) + if data.get("delta_since") is not None + else None + ), + ) + except Exception: + return None + + chunks: list[bytes] = [] + for meta in manifest.chunks: + attempt = 0 + chunk_bytes: bytes | None = None + while attempt < MAX_RETRIES: + cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK)) + if meta.event_id: + cf = cf.id(EventId.parse(meta.event_id)) + else: + cf = cf.identifier(meta.id) + cf = cf.limit(1) + cev = (await self.client.fetch_events(cf, timeout)).to_vec() + if cev: + candidate = base64.b64decode(cev[0].content().encode("utf-8")) + if hashlib.sha256(candidate).hexdigest() == meta.hash: + chunk_bytes = candidate + break + attempt += 1 + if attempt < MAX_RETRIES: + await asyncio.sleep(RETRY_DELAY) + if chunk_bytes is None: + return None + chunks.append(chunk_bytes) + + man_id = getattr(manifest_event, "id", None) + if hasattr(man_id, "to_hex"): + man_id = man_id.to_hex() + self.current_manifest = manifest + self.current_manifest_id = man_id + return manifest, chunks + async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: """Retrieve the latest manifest and all snapshot chunks.""" if self.offline_mode or not self.relays: @@ -414,47 +468,18 @@ class NostrClient: await self._connect_async() pubkey = self.keys.public_key() - f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1) + f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(3) timeout = timedelta(seconds=10) events = (await self.client.fetch_events(f, timeout)).to_vec() if not events: return None - manifest_event = events[0] - manifest_raw = manifest_event.content() - data = json.loads(manifest_raw) - manifest = Manifest( - ver=data["ver"], - algo=data["algo"], - chunks=[ChunkMeta(**c) for c in data["chunks"]], - delta_since=( - int(data["delta_since"]) - if data.get("delta_since") is not None - else None - ), - ) - chunks: list[bytes] = [] - for meta in manifest.chunks: - cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK)) - if meta.event_id: - cf = cf.id(EventId.parse(meta.event_id)) - else: - cf = cf.identifier(meta.id) - cf = cf.limit(1) - cev = (await self.client.fetch_events(cf, timeout)).to_vec() - if not cev: - raise ValueError(f"Missing chunk {meta.id}") - chunk_bytes = base64.b64decode(cev[0].content().encode("utf-8")) - if hashlib.sha256(chunk_bytes).hexdigest() != meta.hash: - raise ValueError(f"Checksum mismatch for chunk {meta.id}") - chunks.append(chunk_bytes) + for manifest_event in events: + result = await self._fetch_chunks_with_retry(manifest_event) + if result is not None: + return result - self.current_manifest = manifest - man_id = getattr(manifest_event, "id", None) - if hasattr(man_id, "to_hex"): - man_id = man_id.to_hex() - self.current_manifest_id = man_id - return manifest, chunks + return None async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: """Publish a delta event referencing a manifest.""" diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index fd91682..ed6ccd5 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -65,3 +65,43 @@ def test_publish_and_fetch_deltas(dummy_nostr_client): assert relay.manifests[-1].delta_since == second_ts deltas = asyncio.run(client.fetch_deltas_since(0)) assert deltas == [d1, d2] + + +def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatch): + import os + import gzip + + client, relay = dummy_nostr_client + monkeypatch.setattr("nostr.client.MAX_RETRIES", 3) + monkeypatch.setattr("nostr.client.RETRY_DELAY", 0) + + data1 = os.urandom(60000) + manifest1, _ = asyncio.run(client.publish_snapshot(data1)) + + data2 = os.urandom(60000) + manifest2, _ = asyncio.run(client.publish_snapshot(data2)) + + missing = manifest2.chunks[0] + if missing.event_id: + relay.chunks.pop(missing.event_id, None) + relay.chunks.pop(missing.id, None) + + relay.filters.clear() + + fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot()) + + assert gzip.decompress(b"".join(chunk_bytes)) == data1 + assert [c.event_id for c in fetched_manifest.chunks] == [ + c.event_id for c in manifest1.chunks + ] + + attempts = sum( + 1 + for f in relay.filters + if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK + and ( + missing.id in getattr(f, "ids", []) + or (missing.event_id and missing.event_id in getattr(f, "ids", [])) + ) + ) + assert attempts == 3 From dfa85ad863d5916bb8959786e35e095458a8658c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 15:00:38 -0400 Subject: [PATCH 3/5] Add attempt_initial_sync and update sync logic --- src/password_manager/manager.py | 32 +++++++++++++++++------ src/tests/test_full_sync_roundtrip.py | 3 ++- src/tests/test_full_sync_roundtrip_new.py | 3 ++- src/tests/test_profiles.py | 7 ++--- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 8434808..05ac948 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1127,7 +1127,7 @@ class PasswordManager: def _worker() -> None: try: if hasattr(self, "nostr_client") and hasattr(self, "vault"): - self.sync_index_from_nostr_if_missing() + self.attempt_initial_sync() if hasattr(self, "sync_index_from_nostr"): self.sync_index_from_nostr() except Exception as exc: @@ -1176,16 +1176,19 @@ class PasswordManager: threading.Thread(target=_worker, daemon=True).start() - def sync_index_from_nostr_if_missing(self) -> None: - """Retrieve the password database from Nostr if it doesn't exist locally. + def attempt_initial_sync(self) -> bool: + """Attempt to download the initial vault snapshot from Nostr. - If no valid data is found or decryption fails, initialize a fresh local - database and publish it to Nostr. + Returns ``True`` if the snapshot was successfully downloaded and the + local index file was written. Returns ``False`` otherwise. The local + index file is not created on failure. """ index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" if index_file.exists(): - return + return True + have_data = False + start = time.perf_counter() try: result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) if result: @@ -1202,10 +1205,23 @@ class PasswordManager: if success: logger.info("Initialized local database from Nostr.") have_data = True - except Exception as e: + except Exception as e: # pragma: no cover - network errors logger.warning(f"Unable to sync index from Nostr: {e}") + finally: + if getattr(self, "verbose_timing", False): + duration = time.perf_counter() - start + logger.info("attempt_initial_sync completed in %.2f seconds", duration) - if not have_data: + return have_data + + def sync_index_from_nostr_if_missing(self) -> None: + """Retrieve the password database from Nostr if it doesn't exist locally. + + If no valid data is found or decryption fails, initialize a fresh local + database and publish it to Nostr. + """ + success = self.attempt_initial_sync() + if not success: self.vault.save_index({"schema_version": LATEST_VERSION, "entries": {}}) try: self.sync_vault() diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py index 2787261..64f110e 100644 --- a/src/tests/test_full_sync_roundtrip.py +++ b/src/tests/test_full_sync_roundtrip.py @@ -47,7 +47,8 @@ def test_full_sync_roundtrip(dummy_nostr_client): manifest_id = relay.manifests[-1].id # Manager B retrieves snapshot - pm_b.sync_index_from_nostr_if_missing() + result = pm_b.attempt_initial_sync() + assert result is True entries = pm_b.entry_manager.list_entries() assert [e[1] for e in entries] == ["site1"] diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py index 2787261..64f110e 100644 --- a/src/tests/test_full_sync_roundtrip_new.py +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -47,7 +47,8 @@ def test_full_sync_roundtrip(dummy_nostr_client): manifest_id = relay.manifests[-1].id # Manager B retrieves snapshot - pm_b.sync_index_from_nostr_if_missing() + result = pm_b.attempt_initial_sync() + assert result is True entries = pm_b.entry_manager.list_entries() assert [e[1] for e in entries] == ["site1"] diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index 7b70d5c..d44e9b7 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -81,6 +81,7 @@ def test_sync_index_missing_bad_data(monkeypatch, dummy_nostr_client): ) monkeypatch.setattr(client, "fetch_deltas_since", lambda *_a, **_k: []) - pm.sync_index_from_nostr_if_missing() - data = pm.vault.load_index() - assert data["entries"] == {} + result = pm.attempt_initial_sync() + assert result is False + index_path = dir_path / "seedpass_entries_db.json.enc" + assert not index_path.exists() From c78765189908a7f796f137bc05a7462714bff544 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 15:21:08 -0400 Subject: [PATCH 4/5] Return all Nostr event IDs --- src/main.py | 17 +++++++++-------- src/password_manager/manager.py | 24 +++++++++++++++++++----- src/seedpass/cli.py | 11 ++++++++--- src/tests/test_cli_doc_examples.py | 6 +++++- src/tests/test_post_sync_messages.py | 7 ++++++- src/tests/test_typer_cli.py | 8 +++++++- 6 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/main.py b/src/main.py index 6219ca5..61da7f2 100644 --- a/src/main.py +++ b/src/main.py @@ -365,14 +365,15 @@ def handle_post_to_nostr( Handles the action of posting the encrypted password index to Nostr. """ try: - event_id = password_manager.sync_vault(alt_summary=alt_summary) - if event_id: - print( - colored( - f"\N{WHITE HEAVY CHECK MARK} Sync complete. Event ID: {event_id}", - "green", - ) - ) + result = password_manager.sync_vault(alt_summary=alt_summary) + if result: + print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green")) + print("Event IDs:") + print(f" manifest: {result['manifest_id']}") + for cid in result["chunk_ids"]: + print(f" chunk: {cid}") + for did in result["delta_ids"]: + print(f" delta: {did}") logging.info("Encrypted index posted to Nostr successfully.") else: print(colored("\N{CROSS MARK} Sync failed…", "red")) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 05ac948..c3c2205 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -3517,8 +3517,10 @@ class PasswordManager: # Re-raise the exception to inform the calling function of the failure raise - def sync_vault(self, alt_summary: str | None = None) -> str | None: - """Publish the current vault contents to Nostr.""" + def sync_vault( + self, alt_summary: str | None = None + ) -> dict[str, list[str] | str] | None: + """Publish the current vault contents to Nostr and return event IDs.""" try: if getattr(self, "offline_mode", False): return None @@ -3526,16 +3528,28 @@ class PasswordManager: if not encrypted: return None pub_snap = getattr(self.nostr_client, "publish_snapshot", None) + manifest = None + event_id = None if callable(pub_snap): if asyncio.iscoroutinefunction(pub_snap): - _, event_id = asyncio.run(pub_snap(encrypted)) + manifest, event_id = asyncio.run(pub_snap(encrypted)) else: - _, event_id = pub_snap(encrypted) + manifest, event_id = pub_snap(encrypted) else: # Fallback for tests using simplified stubs event_id = self.nostr_client.publish_json_to_nostr(encrypted) self.is_dirty = False - return event_id + if event_id is None: + return None + chunk_ids: list[str] = [] + if manifest is not None: + chunk_ids = [c.event_id for c in manifest.chunks if c.event_id] + delta_ids = getattr(self.nostr_client, "_delta_events", []) + return { + "manifest_id": event_id, + "chunk_ids": chunk_ids, + "delta_ids": list(delta_ids), + } except Exception as e: logging.error(f"Failed to sync vault: {e}", exc_info=True) return None diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index d8df065..2ffb0e6 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -419,9 +419,14 @@ def vault_reveal_parent_seed( def nostr_sync(ctx: typer.Context) -> None: """Sync with configured Nostr relays.""" pm = _get_pm(ctx) - event_id = pm.sync_vault() - if event_id: - typer.echo(event_id) + result = pm.sync_vault() + if result: + typer.echo("Event IDs:") + typer.echo(f"- manifest: {result['manifest_id']}") + for cid in result["chunk_ids"]: + typer.echo(f"- chunk: {cid}") + for did in result["delta_ids"]: + typer.echo(f"- delta: {did}") else: typer.echo("Error: Failed to sync vault") diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 44bf430..9937926 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -53,7 +53,11 @@ class DummyPM: self.nostr_client = SimpleNamespace( key_manager=SimpleNamespace(get_npub=lambda: "npub") ) - self.sync_vault = lambda: "event" + self.sync_vault = lambda: { + "manifest_id": "event", + "chunk_ids": ["c1"], + "delta_ids": [], + } self.config_manager = SimpleNamespace( load_config=lambda require_pin=False: {"inactivity_timeout": 30}, set_inactivity_timeout=lambda v: None, diff --git a/src/tests/test_post_sync_messages.py b/src/tests/test_post_sync_messages.py index 2491217..c0273d5 100644 --- a/src/tests/test_post_sync_messages.py +++ b/src/tests/test_post_sync_messages.py @@ -9,12 +9,17 @@ import main def test_handle_post_success(capsys): pm = SimpleNamespace( - sync_vault=lambda alt_summary=None: "abcd", + sync_vault=lambda alt_summary=None: { + "manifest_id": "abcd", + "chunk_ids": ["c1", "c2"], + "delta_ids": ["d1"], + }, ) main.handle_post_to_nostr(pm) out = capsys.readouterr().out assert "✅ Sync complete." in out assert "abcd" in out + assert "c1" in out and "c2" in out and "d1" in out def test_handle_post_failure(capsys): diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index b64a2d1..878fd0c 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -288,7 +288,11 @@ def test_nostr_sync(monkeypatch): def sync_vault(): called["called"] = True - return "evt123" + return { + "manifest_id": "evt123", + "chunk_ids": ["c1"], + "delta_ids": ["d1"], + } pm = SimpleNamespace(sync_vault=sync_vault, select_fingerprint=lambda fp: None) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) @@ -296,6 +300,8 @@ def test_nostr_sync(monkeypatch): assert result.exit_code == 0 assert called.get("called") is True assert "evt123" in result.stdout + assert "c1" in result.stdout + assert "d1" in result.stdout def test_generate_password(monkeypatch): From 576437223a03e7131c71b147d21c4fb94e69101f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 15:39:05 -0400 Subject: [PATCH 5/5] Add tests for sync and snapshot functionality --- src/tests/test_nostr_dummy_client.py | 27 +++++++++++++++++++++++++++ src/tests/test_post_sync_messages.py | 21 +++++++++++++++++++++ src/tests/test_profiles.py | 21 +++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index ed6ccd5..5284a1e 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -105,3 +105,30 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc ) ) assert attempts == 3 + + +def test_fetch_snapshot_uses_event_ids(dummy_nostr_client): + import os + import gzip + + client, relay = dummy_nostr_client + + data = os.urandom(60000) + manifest, _ = asyncio.run(client.publish_snapshot(data)) + + # Remove identifier keys so chunks can only be fetched via event_id + for meta in manifest.chunks: + relay.chunks.pop(meta.id, None) + + relay.filters.clear() + + fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot()) + + assert gzip.decompress(b"".join(chunk_bytes)) == data + + id_filters = [ + f.id_called + for f in relay.filters + if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK + ] + assert id_filters and all(id_filters) diff --git a/src/tests/test_post_sync_messages.py b/src/tests/test_post_sync_messages.py index c0273d5..d45b74b 100644 --- a/src/tests/test_post_sync_messages.py +++ b/src/tests/test_post_sync_messages.py @@ -29,3 +29,24 @@ def test_handle_post_failure(capsys): main.handle_post_to_nostr(pm) out = capsys.readouterr().out assert "❌ Sync failed…" in out + + +def test_handle_post_prints_all_ids(capsys): + pm = SimpleNamespace( + sync_vault=lambda alt_summary=None: { + "manifest_id": "m1", + "chunk_ids": ["c1", "c2"], + "delta_ids": ["d1", "d2"], + } + ) + main.handle_post_to_nostr(pm) + out_lines = capsys.readouterr().out.splitlines() + expected = [ + " manifest: m1", + " chunk: c1", + " chunk: c2", + " delta: d1", + " delta: d2", + ] + for line in expected: + assert any(line in ol for ol in out_lines) diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index d44e9b7..aec32c8 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -85,3 +85,24 @@ def test_sync_index_missing_bad_data(monkeypatch, dummy_nostr_client): assert result is False index_path = dir_path / "seedpass_entries_db.json.enc" assert not index_path.exists() + + +def test_attempt_initial_sync_incomplete_data(monkeypatch, dummy_nostr_client): + client, _relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + dir_path = Path(tmpdir) + vault, _enc = create_vault(dir_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.fingerprint_dir = dir_path + pm.vault = vault + pm.nostr_client = client + pm.sync_vault = lambda *a, **k: None + + # Simulate relay snapshot retrieval failure due to missing chunks + monkeypatch.setattr(client, "fetch_latest_snapshot", lambda: None) + + result = pm.attempt_initial_sync() + assert result is False + index_path = dir_path / "seedpass_entries_db.json.enc" + assert not index_path.exists()