From 45cd27ea4fa0eb9d55c6a3fce33c407f983c41dd Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:06:26 -0500 Subject: [PATCH] tests: add deterministic nostr sync failure-mode coverage --- docs/dev_control_center.md | 11 +- docs/security_readiness_checklist.md | 2 +- memory-update.md | 15 +++ .../test_nostr_resilience_failure_modes.py | 105 ++++++++++++++++++ 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/tests/test_nostr_resilience_failure_modes.py diff --git a/docs/dev_control_center.md b/docs/dev_control_center.md index 41ecdee1..fad35bd7 100644 --- a/docs/dev_control_center.md +++ b/docs/dev_control_center.md @@ -64,8 +64,8 @@ Use these docs as inputs, but treat this file as the decision layer: ## 4) Recommended Next Steps (Priority Order) -1. Advance Nostr resilience validation with deterministic failure-mode suites (then optional real-relay soak lane). -2. Update cutover memo to reflect current reality and remaining concrete blockers. +1. Update cutover memo to reflect current reality and remaining concrete blockers. +2. Advance Nostr resilience validation with optional real-relay soak lane after deterministic suites. 3. Finish supply-chain readiness evidence and release-protection policy. ## 5) Next Slice Definition (Recommended Immediate Work) @@ -105,6 +105,13 @@ Progress update (2026-03-02): - new focused gate `scripts/tui2_coverage_gate.sh` enforces: - `src/seedpass/tui_v2/app.py >= 78%` - `src/seedpass/core/api.py >= 85%` +- Nostr deterministic resilience suite expanded: + - new tests in `src/tests/test_nostr_resilience_failure_modes.py` cover: + - no encrypted data sync attempt + - publish-without-event fallback error handling + - preservation of relay-provided publish errors + - snapshot-missing warning path + - snapshot-fetch exception warning path ## 6) Working Rule diff --git a/docs/security_readiness_checklist.md b/docs/security_readiness_checklist.md index 138429c0..1c228171 100644 --- a/docs/security_readiness_checklist.md +++ b/docs/security_readiness_checklist.md @@ -8,7 +8,7 @@ Status legend: `Not Started`, `In Progress`, `Blocked`, `Done` | 2 | Crypto and key management review | Done | Security / Core | `docs/crypto_key_management_review.md`, `src/tests/test_kdf_modes.py`, `src/tests/test_export_plaintext_policy.py` | | 3 | Secret handling and local data exposure hardening | Done | Core | `docs/secret_handling_local_exposure_review.md`, `src/tests/test_memory_protection.py`, `src/tests/test_clipboard_utils.py`, `src/tests/test_fingerprint_encryption.py`, `src/tests/test_api_new_endpoints.py`, `src/tests/test_bip85_derivation_path.py` | | 4 | Backup and restore integrity validation | In Progress | Core / QA | `src/tests/test_account_roundtrip_restore.py`, `src/tests/test_portable_backup.py` | -| 5 | Nostr sync security and resilience validation | In Progress | Core / QA | `src/tests/test_nostr_real.py`, `src/tests/test_nostr_index_size.py` | +| 5 | Nostr sync security and resilience validation | In Progress | Core / QA | `src/tests/test_nostr_real.py`, `src/tests/test_nostr_index_size.py`, `src/tests/test_nostr_resilience_failure_modes.py` | | 6 | Auth, lock/unlock, and access-control hardening | Done | Core | `docs/auth_lock_unlock_access_review.md`, `src/tests/test_api_new_endpoints.py`, `src/tests/test_vault_lock_flag.py`, `src/tests/test_inactivity_lock.py`, `src/tests/test_unlock_sync.py` | | 7 | Testing gates and quality thresholds | In Progress | QA | `scripts/check_critical_coverage.py`, `scripts/tui2_coverage_gate.sh`, CI workflows | | 8 | Supply chain and release integrity | In Progress | DevOps | `docs/supply_chain_release_integrity.md`, `.github/workflows/release-integrity.yml`, `scripts/release_integrity.py`, `src/tests/test_release_integrity_utils.py` | diff --git a/memory-update.md b/memory-update.md index 5df2316c..383e8f17 100644 --- a/memory-update.md +++ b/memory-update.md @@ -721,3 +721,18 @@ - `src/seedpass/core/api.py: 86.64%` - `src/seedpass/tui_v2/app.py: 79.87%` - `python3 scripts/check_critical_coverage.py artifacts/coverage/coverage.json --json-output artifacts/coverage/critical_gate.full.json` -> PASS. + +## 2026-03-02 Nostr resilience deterministic failure-mode suite expansion +- Added new deterministic resilience tests in `src/tests/test_nostr_resilience_failure_modes.py` targeting `PasswordManager` Nostr sync error paths. +- New covered scenarios: + - `sync_vault_async` returns `None` and sets explicit error when encrypted index bytes are unavailable. + - `sync_vault_async` preserves relay/publish error text when `publish_snapshot` fails to produce an event ID. + - `sync_vault_async` sets fallback publish failure error when no relay error text is provided. + - `sync_index_from_nostr_async` emits warning notification when snapshot fetch returns no result and client has `last_error`. + - `sync_index_from_nostr_async` emits warning notification on snapshot-fetch exceptions. +- Validation run: + - `pytest -q src/tests/test_nostr_resilience_failure_modes.py src/tests/test_background_vault_sync_paths.py src/tests/test_post_sync_messages.py src/tests/test_offline_mode_behavior.py` + - Result: `16 passed`. +- Docs updated: + - `docs/security_readiness_checklist.md` now includes `test_nostr_resilience_failure_modes.py` under Nostr resilience evidence. + - `docs/dev_control_center.md` progress notes updated to reflect deterministic Nostr resilience suite expansion. diff --git a/src/tests/test_nostr_resilience_failure_modes.py b/src/tests/test_nostr_resilience_failure_modes.py new file mode 100644 index 00000000..fd4f268d --- /dev/null +++ b/src/tests/test_nostr_resilience_failure_modes.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from seedpass.core.manager import PasswordManager + + +def test_sync_vault_async_sets_last_error_when_no_encrypted_data() -> None: + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = False + pm.get_encrypted_data = lambda: b"" + pm.nostr_client = SimpleNamespace(last_error=None) + + result = asyncio.run(pm.sync_vault_async()) + + assert result is None + assert pm.nostr_client.last_error == "No encrypted index data available to sync." + + +def test_sync_vault_async_preserves_existing_error_when_publish_returns_none() -> None: + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = False + pm.is_dirty = True + pm.get_encrypted_data = lambda: b"encrypted" + pm.state_manager = None + client = SimpleNamespace(last_error=None, get_delta_events=lambda: []) + + def _publish_snapshot(_enc): + client.last_error = "relay timeout" + return None, None + + client.publish_snapshot = _publish_snapshot + pm.nostr_client = client + + result = asyncio.run(pm.sync_vault_async()) + + assert result is None + assert pm.nostr_client.last_error == "relay timeout" + assert pm.is_dirty is False + + +def test_sync_vault_async_sets_default_error_when_publish_returns_none() -> None: + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = False + pm.is_dirty = True + pm.get_encrypted_data = lambda: b"encrypted" + pm.state_manager = None + pm.nostr_client = SimpleNamespace( + last_error=None, + publish_snapshot=lambda _enc: (None, None), + get_delta_events=lambda: [], + ) + + result = asyncio.run(pm.sync_vault_async()) + + assert result is None + assert pm.nostr_client.last_error == ( + "Failed to publish encrypted index to configured relays." + ) + assert pm.is_dirty is False + + +def test_sync_index_from_nostr_async_notifies_last_error_when_snapshot_missing() -> None: + notifications: list[tuple[str, str]] = [] + + async def _no_snapshot(): + return None + + pm = PasswordManager.__new__(PasswordManager) + pm.current_fingerprint = "fp" + pm.nostr_client = SimpleNamespace( + relays=["wss://relay-1"], + last_error="timeout waiting for snapshot", + fetch_latest_snapshot=_no_snapshot, + ) + pm.notify = lambda msg, level="INFO": notifications.append((msg, level)) + + asyncio.run(pm.sync_index_from_nostr_async()) + + assert notifications == [ + ("Sync failed: timeout waiting for snapshot", "WARNING"), + ] + + +def test_sync_index_from_nostr_async_notifies_exception_when_fetch_raises() -> None: + notifications: list[tuple[str, str]] = [] + + async def _boom(): + raise RuntimeError("relay unreachable") + + pm = PasswordManager.__new__(PasswordManager) + pm.current_fingerprint = "fp" + pm.nostr_client = SimpleNamespace( + relays=["wss://relay-1"], + last_error=None, + fetch_latest_snapshot=_boom, + ) + pm.notify = lambda msg, level="INFO": notifications.append((msg, level)) + + asyncio.run(pm.sync_index_from_nostr_async()) + + assert notifications == [ + ("Sync failed: relay unreachable", "WARNING"), + ]