improved tests

This commit is contained in:
thePR0M3TH3AN committed 2026-02-25 18:07:22 -05:00
1 parent f7f81a29d7
commit c3eef44ab7
13 files changed
+1271 -50

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
# SeedPass Crypto & Key Management Review (Item 2)
Status: `In Progress (P0 partially implemented)`
Date: `2026-02-25`
## Scope Reviewed
1. `src/utils/key_derivation.py`
2. `src/seedpass/core/encryption.py`
3. `src/seedpass/core/vault.py`
4. `src/seedpass/core/portable_backup.py`
5. `src/seedpass/core/manager.py` (unlock/setup key paths)
6. `src/seedpass/core/config_manager.py` (KDF config surface)
## Crypto Inventory (Current)
1. At-rest/index encryption:
- AES-GCM via `cryptography` (`EncryptionManager.encrypt_data/decrypt_data`).
- Format marker: `V3|` + 96-bit nonce + ciphertext+tag.
2. Legacy compatibility:
- Fernet legacy decrypt paths (`V2:` fallback and raw legacy blobs).
3. Password-to-seed key derivation:
- PBKDF2-HMAC-SHA256 (`derive_key_from_password`) with fingerprint-derived salt.
- Optional Argon2id path (`derive_key_from_password_argon2`).
4. Seed-to-storage key derivation:
- deterministic hierarchy from mnemonic seed (`derive_index_key*`).
5. Portable backup encryption:
- AES-GCM using key derived from parent seed (`derive_index_key`).
6. Integrity checks:
- Cipher authentication tags (AES-GCM).
- JSON checksum validation for portable backups.
## Exit Criteria Check (Item 2)
1. Primitive/parameter review completed: `In Progress`
2. Nonce/key lifecycle reviewed: `In Progress`
3. Negative tests for corruption/tampering/wrong key path: `Partially Done`
## Findings
### Strong / Acceptable
1. AES-GCM is used for current encrypted payloads.
2. Migration compatibility from legacy formats is heavily tested.
3. Portable backup import verifies checksum after decrypt.
4. Nostr restore paths include strict error handling and migration fallback.
### Gaps / Risks
1. PBKDF2 default is low for 2026 threat levels:
- Config default appears to be `50_000` iterations.
- Legacy fallbacks include `50_000` and `100_000`.
2. Argon2 config drift:
- `argon2_time_cost` exists in config, but active unlock code constructs
`KdfConfig(salt_b64=...)` without reading configured `time_cost`.
- This can create false confidence that tuning is active.
3. AES-GCM nonce reuse guard:
- Additional guard uses CRC32 of nonce in-memory.
- CRC32 collision can create false positives under high operation counts.
- Not persisted across restarts, so not a true global nonce registry.
4. Optional plaintext export mode:
- `encrypt=False` in portable export intentionally permits plaintext backups.
- This is a policy risk if used accidentally in production workflows.
5. KDF metadata consistency:
- File wrapper carries `kdf` metadata, but operational seed-unlock
parameters are partly config-driven and partly hardcoded/defaulted.
- Clear authoritative KDF policy per profile is not yet centralized.
## Recommended Remediation Plan
### P0 (Do Next)
1. Wire Argon2 runtime config into active unlock paths:
- In manager setup/load paths, pass configured `argon2_time_cost` (and any
agreed memory/parallelism values) into `KdfConfig.params`.
- Progress: completed in `PasswordManager._derive_seed_key` and wired across
seed-key callsites; tests added in `src/tests/test_kdf_modes.py`.
2. Raise PBKDF2 default for new profiles:
- Choose a higher baseline and keep backward compatibility via fallback.
3. Add explicit warnings for plaintext export mode:
- Keep capability, but require clear confirmation and warning text.
### P1
1. Replace CRC32 nonce tracking with deterministic monotonic counter mode for
backup/index encryption or remove CRC guard and rely on 96-bit randomness.
2. Define a single profile KDF policy object:
- Algorithm + parameters + migration behavior in one place.
3. Expand negative tests to include:
- wrong Argon2 params,
- tampered kdf metadata wrapper,
- downgraded KDF policy attempts.
### P2
1. Add release-time crypto policy checks:
- lint/test gate ensuring configured KDF policy is actually used in unlock paths.
## Test Coverage Snapshot (Relevant)
1. Legacy migration and iteration fallback tests exist.
2. Encryption fuzz and corruption tests exist.
3. Portable backup corruption/checksum tests exist.
4. Additional tests needed for active Argon2 parameter usage in unlock paths.
## Proposed Completion Criteria for Item 2
Item 2 can move to `Done` when:
1. Argon2 config is verifiably wired into unlock path and covered by tests.
2. PBKDF2 policy baseline is explicitly set and documented for new profiles.
3. Plaintext export policy is documented with explicit user safeguards.
4. One nonce-lifecycle decision is implemented (improved guard or rationale for removal).
5. Checklist evidence links are updated to include merged PR/tests.
+5
View File
@@ -28,3 +28,8 @@ PY
```
The selected `time_cost` is stored in the profile's configuration and used for subsequent key derivations.
## Readiness Documents
- Production readiness checklist: `docs/security_readiness_checklist.md`
- Threat model draft: `docs/threat_model.md`
+63
View File
@@ -0,0 +1,63 @@
# SeedPass Security Readiness Checklist
Status legend: `Not Started`, `In Progress`, `Blocked`, `Done`
| # | Area | Status | Owner | Evidence / Link |
|---|------|--------|-------|-----------------|
| 1 | Threat model (attacker profiles, trust boundaries, assumptions) | Done | Security / Core | `docs/threat_model.md` (provisional approvals recorded 2026-02-25) |
| 2 | Crypto and key management review | In Progress | Security / Core | `docs/crypto_key_management_review.md`, `src/tests/test_kdf_modes.py` |
| 3 | Secret handling and local data exposure hardening | Not Started | Core | |
| 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` |
| 6 | Auth, lock/unlock, and access-control hardening | Not Started | Core | |
| 7 | Testing gates and quality thresholds | In Progress | QA | `scripts/check_critical_coverage.py`, CI workflows |
| 8 | Supply chain and release integrity | Not Started | DevOps | |
| 9 | Operational readiness and incident runbooks | Not Started | Security / Ops | |
| 10 | External audit and staged production rollout | Not Started | Maintainers | |
## Exit Criteria Per Item
1. Threat model:
- Assets, trust boundaries, and attacker capabilities documented.
- Explicit assumptions and non-goals documented.
- Top risks prioritized with mitigations and owners.
2. Crypto and key management:
- Primitive/parameter review completed.
- Nonce/key lifecycle reviewed.
- Negative tests for corruption/tampering/wrong key path.
3. Secret handling:
- No secret leakage in logs/temp files/exports by default.
- Clipboard, memory lifetime, and file permission checks documented.
4. Backup/restore:
- Seed-only recovery and encrypted export/import validated.
- Corruption and checksum mismatch behavior validated.
5. Nostr:
- Real-relay publish/retrieve/restore tested.
- Failure modes (delay/outage/stale state) tested.
6. Auth/access:
- Lock/unlock/timeout/quick unlock behavior reviewed with tests.
7. Testing gates:
- Critical module coverage gates enforced.
- Network and stress tests are opt-in and documented.
8. Supply chain:
- Locked dependencies, CVE review process, signed artifacts/checksums.
9. Operational:
- Incident playbooks and recovery drills documented.
10. External validation:
- Independent audit completed.
- Critical findings resolved before broad production use.
## How To Use
1. Update each row with current status and owner.
2. Attach concrete evidence links (PRs, test files, docs, reports).
3. Treat `Done` as requiring evidence for all exit criteria.
+173
View File
@@ -0,0 +1,173 @@
# SeedPass Threat Model (Draft v0.2)
## Scope
This draft covers SeedPass core vault operations, local storage, portable backups,
and Nostr synchronization paths.
## Security Objectives
1. Confidentiality of parent seed and derived secrets.
2. Integrity of vault/index data across local and synced states.
3. Reliable recovery from seed phrase and/or encrypted backup.
4. Deterministic derivation of artifacts across supported platforms/versions.
## Assets
1. Parent seed phrase.
2. Derived artifacts (passwords, TOTP secrets, key material).
3. Encrypted vault index (`seedpass_entries_db.json.enc` and equivalents).
4. Portable backup payloads and metadata.
5. Account metadata:
- Fingerprints.
- Relay configuration.
- Sync state (manifest IDs, timestamps).
## Trust Boundaries
1. Local host boundary:
- Application process, filesystem, clipboard, terminal.
2. Network boundary:
- Nostr relay transport and storage (untrusted relays).
3. Backup boundary:
- Exported files and any external backup path.
4. Build/dependency boundary:
- Package dependencies, installers, and release artifacts.
## Attacker Profiles
1. Remote observer:
- Can observe Nostr traffic metadata and timing; cannot break strong cryptography.
2. Malicious relay operator:
- Can drop, reorder, replay, or withhold events; can inspect ciphertext and metadata.
3. Local unprivileged attacker:
- Can read user-level files on a compromised host.
4. Local privileged attacker / malware:
- Can inspect process memory, keypresses, clipboard, and runtime state.
5. Supply-chain attacker:
- Can attempt dependency or build artifact compromise.
## Assumptions
1. Cryptographic primitives and libraries are implemented correctly.
2. User keeps parent seed secret and offline where possible.
3. Host OS is not fully compromised during sensitive operations.
4. Relay operators are untrusted for confidentiality/integrity.
5. Users can securely verify installation provenance.
## Non-Goals
1. Protection against a fully compromised endpoint with active malware.
2. Guaranteed metadata privacy from global traffic analysis.
3. Protection if the parent seed is disclosed.
## High-Risk Scenarios
1. Parent seed leakage:
- Impact: total compromise of all deterministic artifacts.
- Mitigations: strict secret handling, no logging, secure backup guidance.
2. Local plaintext leakage (logs/temp/clipboard):
- Impact: credential theft despite encrypted-at-rest vault.
- Mitigations: clipboard auto-clear, log redaction, temp file minimization.
3. Relay manipulation/replay/stale restore:
- Impact: rollback or inconsistent state across devices.
- Mitigations: checksum verification, manifest/delta integrity checks, explicit sync guidance.
4. Export backup mishandling:
- Impact: offline brute-force or direct exposure if unencrypted export is used.
- Mitigations: encrypted export default, warnings, permissions hardening.
5. Dependency/build compromise:
- Impact: malicious code exfiltration of seed/secrets.
- Mitigations: lockfiles + hashes, dependency audit, signed releases.
## Required Decisions (Item 1 Completion Gate)
1. Acceptable metadata leakage:
- Are payload size/timing/fingerprint-derived identifiers acceptable for production use?
2. Endpoint compromise stance:
- What user guidance is mandatory for host hardening and safe operation?
3. Recovery policy:
- Which recovery path is canonical for production: seed-only, backup-only, or both?
4. Relay trust posture:
- Minimum relay set diversity and failover recommendations.
5. Release trust chain:
- Required artifact signing and verification process.
## Proposed Defaults
Status: `Approved (Provisional)`
1. Metadata leakage posture:
- Proposed default: Accept ciphertext-size and sync-timing leakage as an
explicit tradeoff for relay-based recovery.
- Requirement: Document this clearly in user-facing security docs and warn
users not to treat relay metadata as private.
- Constraint: No plaintext secret material or deterministic secret-derived
fields should be published to relays.
2. Endpoint compromise stance:
- Proposed default: SeedPass is not secure against active local malware or
a fully compromised host.
- Requirement: Add explicit operational guidance:
- Use dedicated, hardened device profile for high-value seeds.
- Keep offline mode enabled unless sync is required.
- Minimize clipboard usage for sensitive workflows.
- Requirement: Add periodic hygiene checklist in docs.
3. Recovery policy:
- Proposed default: Canonical recovery is seed-first; encrypted portable
backup import is secondary for convenience and fast restore.
- Requirement: Every release must preserve both:
- seed-only deterministic recovery path
- encrypted backup export/import compatibility
- Requirement: Keep migration tests for legacy indexes and backup formats.
4. Relay trust posture:
- Proposed default: Minimum 3 independent relays from different operators.
- Requirement: Recommend at least 1 high-availability relay + 2 alternates.
- Requirement: Treat relay data as untrusted input; require checksum and
integrity validation before accepting restored data.
- Requirement: Keep retry/backoff behavior for eventual consistency.
5. Release trust chain:
- Proposed default: Signed tags/releases plus published checksums for all
distributable artifacts.
- Requirement: Dependency lockfile with hashes remains mandatory in CI.
- Requirement: Reject release if provenance checks fail or lockfile drift is
detected without review.
## Decision Approval Record
Use this table to convert proposed defaults to approved policy.
| Decision Area | Proposed | Approved | Date | Reviewer | Notes |
|---|---|---|---|---|---|
| Metadata leakage posture | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Accepted as explicit recovery tradeoff. |
| Endpoint compromise stance | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Not secure against active local malware. |
| Recovery policy | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Seed-first canonical recovery. |
| Relay trust posture | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Minimum 3 independent relays recommended. |
| Release trust chain | Yes | Yes | 2026-02-25 | Maintainers (Provisional) | Signed releases + checksums required. |
## Initial Risk Ranking
1. Critical: parent seed compromise.
2. High: endpoint malware/memory scraping.
3. High: insecure backup handling and accidental plaintext exposure.
4. Medium: relay replay/staleness causing wrong-state restore.
5. Medium: supply-chain compromise.
## Mapping to Existing Evidence
1. Real-relay restore/sync E2E coverage:
- `src/tests/test_nostr_real.py`
2. Portable backup/import integrity tests:
- `src/tests/test_portable_backup.py`
3. Account lifecycle roundtrip tests:
- `src/tests/test_account_roundtrip_restore.py`
4. Large-index relay syncability probe:
- `src/tests/test_nostr_index_size.py`
## Next Actions
1. Convert provisional approvals to formal release-policy signoff before external audit.
2. Add a risk register table with owner and target date per high-risk scenario.
3. Link resulting mitigations back to code/tests/docs in `docs/security_readiness_checklist.md`.
+69
View File
@@ -5,3 +5,72 @@
- Confirmed that `landing/index.html` no longer contains `innerHTML` violations.
- Confirmed `torch-config.json` correctly defines scheduler handoff commands.
- Consolidated resolved issues into `KNOWN_ISSUES.md` for historical tracking.
# Learnings from test-coverage run
- `artifacts/coverage/coverage.full.json` is useful for choosing high-impact gaps quickly; `src/utils/input_utils.py` was at 25.00% and had no direct tests.
- Added deterministic unit tests for `timed_input` POSIX/Windows dispatch, timeout handling, and backspace behavior without relying on real terminal input.
- Added positive-path tests for `utils/terminal_utils` header/notification rendering to complement existing failure-path tests.
- Focused coverage after changes: `src/utils/input_utils.py` reached 100% and `src/utils/terminal_utils.py` reached 88% in targeted runs.
# Learnings from entry-type verification run
- Added an enum-driven regression suite to verify every `EntryType` value can be added, retrieved, and filtered via `EntryManager`.
- Guard test now asserts `ALL_ENTRY_TYPES` stays in sync with the `EntryType` enum, so introducing a new kind requires explicit test coverage updates.
# Learnings from account roundtrip restore run
- Added end-to-end regression tests for account lifecycle recovery:
Nostr sync -> local account deletion -> seed-based restore -> entry integrity checks.
- Added parallel lifecycle regression for portable backups:
export -> local account deletion -> import with seed -> entry integrity checks.
- `PasswordManager` test harnesses that call `sync_vault()` need `state_manager` set (even `None`) to avoid `AttributeError` in `sync_vault_async`.
# Learnings from real-relay roundtrip run
- Added an opt-in `@pytest.mark.network` roundtrip test in `test_nostr_real.py` that uses live relays and validates sync -> delete local index -> seed-based restore.
- Real-relay tests should isolate keyspace per run using a random `account_index` so snapshot queries do not collide with prior runs.
- Relay propagation can be eventual; bounded retry/backoff around `attempt_initial_sync()` improves stability for live network execution.
# Learnings from live relay + portable chain run
- Added an opt-in live test that chains both recovery paths: real-relay sync and portable export/import, then re-syncs and restores again from relays.
- Keeping `fingerprint` + `account_index` consistent across source/import/restore clients preserves deterministic relay identity for end-to-end verification.
# Learnings from nostr-index-size gating update
- `test_nostr_index_size_limits` remains discoverable in test collection but now requires explicit `NOSTR_INDEX_SIZE_E2E=1` to execute.
- This avoids automatic execution in CI desktop jobs while preserving manual E2E stress capability.
# Learnings from 1000-entry live index-size run
- With `NOSTR_INDEX_SIZE_E2E=1`, live relays handled checkpoints up to 1000 entries with successful publish/retrieve at every batch.
- Observed encrypted payload growth is strongly superlinear (about 30 KB at 100 entries to about 4.6 MB at 1000 entries), which is useful for practical relay size planning.
# Learnings from security checklist + threat model kickoff
- Added `docs/security_readiness_checklist.md` with status/owner/evidence fields and explicit exit criteria per security readiness item.
- Started item 1 by drafting `docs/threat_model.md` with scope, assets, trust boundaries, attacker profiles, assumptions, non-goals, and prioritized risks.
- Added five explicit decision gates to complete item 1 (metadata leakage posture, endpoint stance, recovery policy, relay trust posture, release trust chain).
# Learnings from threat model defaults pass
- Added concrete proposed defaults and requirements for all five item-1 decision gates in `docs/threat_model.md`.
- Added a decision approval table so maintainers can formally approve/reject each gate and close item 1 with traceable signoff.
# Learnings from threat model provisional approval
- Applied best-judgment provisional approvals for all five decision gates and recorded them in `docs/threat_model.md`.
- Marked checklist item 1 as `Done` in `docs/security_readiness_checklist.md` with evidence and approval date.
# Learnings from crypto/key-management review kickoff
- Added `docs/crypto_key_management_review.md` and moved checklist item 2 to `In Progress`.
- Identified immediate P0 gaps: PBKDF2 baseline appears low for modern threat levels and Argon2 `time_cost` config is not clearly wired through all active unlock paths.
- Documented prioritized remediation plan and explicit completion criteria for closing item 2.
# Learnings from argon2 wiring remediation
- Added `PasswordManager._derive_seed_key` and related config helpers so seed-key derivation consistently applies configured `kdf_mode` and Argon2 `time_cost`.
- Replaced multiple direct seed-key derivation callsites with the shared helper across setup/unlock/import/password-change flows.
- Added focused tests in `src/tests/test_kdf_modes.py` validating Argon2 `time_cost` propagation and PBKDF2 iteration override behavior.
+59 -48
View File
@@ -352,6 +352,51 @@ class PasswordManager:
self.KEY_PW_DERIVE = kd(master, b"seedpass:v1:pw")
self.KEY_TOTP_DET = kd(master, b"seedpass:v1:totp")
def _get_kdf_mode(self) -> str:
if getattr(self, "config_manager", None):
return self.config_manager.get_kdf_mode()
return "pbkdf2"
def _get_kdf_iterations(self) -> int:
if getattr(self, "config_manager", None):
return self.config_manager.get_kdf_iterations()
return 50_000
def _get_argon2_time_cost(self) -> int:
if getattr(self, "config_manager", None):
return self.config_manager.get_argon2_time_cost()
return 2
def _derive_seed_key(
self,
password: str,
fingerprint: str,
*,
mode: str | None = None,
iterations: int | None = None,
) -> bytes:
"""Derive the profile seed-encryption key from current KDF settings."""
chosen_mode = mode or self._get_kdf_mode()
if chosen_mode == "argon2":
salt = hashlib.sha256(fingerprint.encode()).digest()[:16]
cfg = KdfConfig(
params={
"time_cost": self._get_argon2_time_cost(),
"memory_cost": 64 * 1024,
"parallelism": 8,
},
salt_b64=base64.b64encode(salt).decode(),
)
return derive_key_from_password_argon2(password, cfg)
if chosen_mode != "pbkdf2":
logger.warning("Unknown kdf_mode '%s'; falling back to pbkdf2", chosen_mode)
iter_count = (
iterations if iterations is not None else self._get_kdf_iterations()
)
return derive_key_from_password(password, fingerprint, iterations=iter_count)
def ensure_key_hierarchy(self) -> None:
"""Ensure sub-keys are derived from the current parent seed."""
if (
@@ -733,14 +778,12 @@ class PasswordManager:
seed_mgr: EncryptionManager | None = None
for iter_try in dict.fromkeys(iter_candidates):
try:
if mode == "argon2":
salt = hashlib.sha256(salt_fp.encode()).digest()[:16]
cfg = KdfConfig(salt_b64=base64.b64encode(salt).decode())
seed_key = derive_key_from_password_argon2(password, cfg)
else:
seed_key = derive_key_from_password(
password, salt_fp, iterations=iter_try
)
seed_key = self._derive_seed_key(
password,
salt_fp,
mode=mode,
iterations=iter_try if mode != "argon2" else None,
)
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
print("Decrypting seed...")
self.parent_seed = seed_mgr.decrypt_parent_seed()
@@ -814,14 +857,9 @@ class PasswordManager:
else 50_000
)
salt_fp = fingerprint_dir.name
if mode == "argon2":
salt = hashlib.sha256(salt_fp.encode()).digest()[:16]
cfg = KdfConfig(salt_b64=base64.b64encode(salt).decode())
seed_key = derive_key_from_password_argon2(password, cfg)
else:
seed_key = derive_key_from_password(
password, salt_fp, iterations=iterations
)
seed_key = self._derive_seed_key(
password, salt_fp, mode=mode, iterations=iterations
)
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
self.parent_seed = seed_mgr.decrypt_parent_seed()
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
@@ -948,14 +986,7 @@ class PasswordManager:
raise SeedPassError("Seed profile directory not found.")
# Derive encryption key from password using selected fingerprint
iterations = (
self.config_manager.get_kdf_iterations()
if getattr(self, "config_manager", None)
else 50_000
)
key = derive_key_from_password(
password, selected_fingerprint, iterations=iterations
)
key = self._derive_seed_key(password, selected_fingerprint)
seed_mgr = EncryptionManager(key, fingerprint_dir)
self.vault = Vault(seed_mgr, fingerprint_dir)
@@ -1125,14 +1156,7 @@ class PasswordManager:
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
self.derive_key_hierarchy(seed_bytes)
index_key = base64.urlsafe_b64encode(self.KEY_STORAGE)
iterations = (
self.config_manager.get_kdf_iterations()
if getattr(self, "config_manager", None)
else 50_000
)
seed_key = derive_key_from_password(
password, fingerprint, iterations=iterations
)
seed_key = self._derive_seed_key(password, fingerprint)
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
@@ -1323,14 +1347,7 @@ class PasswordManager:
seed_bytes = Bip39SeedGenerator(seed).Generate()
self.derive_key_hierarchy(seed_bytes)
index_key = base64.urlsafe_b64encode(self.KEY_STORAGE)
iterations = (
self.config_manager.get_kdf_iterations()
if getattr(self, "config_manager", None)
else 50_000
)
seed_key = derive_key_from_password(
password, fingerprint_dir.name, iterations=iterations
)
seed_key = self._derive_seed_key(password, fingerprint_dir.name)
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
@@ -4282,10 +4299,7 @@ class PasswordManager:
if mode == PortableMode.NONE.value:
try:
password = prompt_new_password()
iterations = self.config_manager.get_kdf_iterations()
seed_key = derive_key_from_password(
password, self.current_fingerprint, iterations=iterations
)
seed_key = self._derive_seed_key(password, self.current_fingerprint)
seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir)
seed_mgr.encrypt_parent_seed(self.parent_seed)
self.store_hashed_password(password)
@@ -4584,10 +4598,7 @@ class PasswordManager:
# Create a new encryption manager with the new password
new_key = base64.urlsafe_b64encode(self.KEY_STORAGE)
iterations = self.config_manager.get_kdf_iterations()
seed_key = derive_key_from_password(
new_password, self.current_fingerprint, iterations=iterations
)
seed_key = self._derive_seed_key(new_password, self.current_fingerprint)
seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir)
new_enc_mgr = EncryptionManager(new_key, self.fingerprint_dir)
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import shutil
from pathlib import Path
from helpers import TEST_PASSWORD, TEST_SEED, create_vault
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.manager import EncryptionMode, PasswordManager
from seedpass.core.portable_backup import export_backup, import_backup
def _init_pm(dir_path: Path, nostr_client=None) -> PasswordManager:
vault, enc_mgr = create_vault(dir_path, TEST_SEED, TEST_PASSWORD)
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 = nostr_client
pm.fingerprint_dir = dir_path
pm.current_fingerprint = "fp"
pm.parent_seed = TEST_SEED
pm.is_dirty = False
pm.secret_mode_enabled = False
pm.state_manager = None
pm.offline_mode = False
pm.notify = lambda *a, **k: None
return pm
def _add_sample_entries(entry_mgr: EntryManager) -> None:
entry_mgr.add_entry(
"example.com",
14,
username="alice",
url="https://example.com",
notes="password note",
tags=["web"],
)
entry_mgr.add_totp(
"example-totp",
TEST_SEED,
deterministic=True,
period=45,
digits=8,
notes="totp note",
tags=["otp"],
)
entry_mgr.add_key_value(
"api-token",
"token",
"abc123",
notes="kv note",
tags=["api"],
)
def _assert_sample_entries(entry_mgr: EntryManager) -> None:
pw = entry_mgr.retrieve_entry(0)
totp = entry_mgr.retrieve_entry(1)
kv = entry_mgr.retrieve_entry(2)
assert pw is not None
assert pw["kind"] == "password"
assert pw["label"] == "example.com"
assert pw["username"] == "alice"
assert pw["url"] == "https://example.com"
assert pw["length"] == 14
assert pw["notes"] == "password note"
assert totp is not None
assert totp["kind"] == "totp"
assert totp["label"] == "example-totp"
assert totp["deterministic"] is True
assert totp["index"] == 0
assert totp["period"] == 45
assert totp["digits"] == 8
assert totp["notes"] == "totp note"
assert kv is not None
assert kv["kind"] == "key_value"
assert kv["label"] == "api-token"
assert kv["key"] == "token"
assert kv["value"] == "abc123"
assert kv["notes"] == "kv note"
labels = [item[1] for item in entry_mgr.list_entries(verbose=False)]
assert labels == ["example.com", "example-totp", "api-token"]
def test_account_roundtrip_via_nostr_seed_restore(tmp_path, make_dummy_nostr_client):
client, relay = make_dummy_nostr_client(tmp_path / "relay")
source_dir = tmp_path / "account-source"
source_dir.mkdir()
pm_source = _init_pm(source_dir, client)
_add_sample_entries(pm_source.entry_manager)
sync_result = pm_source.sync_vault()
assert sync_result is not None
assert relay.manifests
shutil.rmtree(source_dir)
assert not source_dir.exists()
restored_dir = tmp_path / "account-restored"
restored_dir.mkdir()
pm_restored = _init_pm(restored_dir, client)
restored = pm_restored.attempt_initial_sync()
assert restored is True
pm_restored.entry_manager.clear_cache()
_assert_sample_entries(pm_restored.entry_manager)
def test_account_roundtrip_via_export_import_seed_restore(tmp_path):
source_dir = tmp_path / "account-source"
source_dir.mkdir()
pm_source = _init_pm(source_dir, None)
_add_sample_entries(pm_source.entry_manager)
export_path = tmp_path / "seedpass-portable-export.json"
path = export_backup(
pm_source.vault,
pm_source.backup_manager,
dest_path=export_path,
parent_seed=TEST_SEED,
encrypt=True,
)
assert path.exists()
shutil.rmtree(source_dir)
assert not source_dir.exists()
restored_dir = tmp_path / "account-restored"
restored_dir.mkdir()
pm_restored = _init_pm(restored_dir, None)
import_backup(
pm_restored.vault,
pm_restored.backup_manager,
path,
parent_seed=TEST_SEED,
)
pm_restored.entry_manager.clear_cache()
_assert_sample_entries(pm_restored.entry_manager)
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from pathlib import Path
import pytest
from helpers import TEST_PASSWORD, TEST_SEED, create_vault
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.entry_types import ALL_ENTRY_TYPES, EntryType
def _make_entry_manager(tmp_path: Path) -> EntryManager:
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
return EntryManager(vault, backup_mgr)
def _add_entry_for_type(entry_mgr: EntryManager, entry_type: EntryType) -> int:
if entry_type == EntryType.PASSWORD:
return entry_mgr.add_entry(
"Password Entry",
16,
username="alice",
url="https://example.com",
notes="pw-note",
tags=["auth"],
)
if entry_type == EntryType.TOTP:
entry_mgr.add_totp(
"TOTP Entry",
TEST_SEED,
deterministic=True,
period=45,
digits=8,
notes="totp-note",
tags=["otp"],
)
return 0
if entry_type == EntryType.SSH:
return entry_mgr.add_ssh_key("SSH Entry", TEST_SEED, notes="ssh-note")
if entry_type == EntryType.SEED:
return entry_mgr.add_seed(
"Seed Entry", TEST_SEED, words_num=12, notes="seed-note", tags=["seed"]
)
if entry_type == EntryType.PGP:
return entry_mgr.add_pgp_key(
"PGP Entry",
TEST_SEED,
key_type="ed25519",
user_id="[email protected]",
notes="pgp-note",
tags=["pgp"],
)
if entry_type == EntryType.NOSTR:
return entry_mgr.add_nostr_key("Nostr Entry", TEST_SEED, notes="nostr-note")
if entry_type == EntryType.KEY_VALUE:
return entry_mgr.add_key_value(
"KV Entry",
"api_key",
"secret-value",
notes="kv-note",
tags=["kv"],
)
if entry_type == EntryType.MANAGED_ACCOUNT:
return entry_mgr.add_managed_account(
"Managed Entry", TEST_SEED, notes="managed-note", tags=["managed"]
)
raise AssertionError(f"Unhandled entry type in test: {entry_type}")
def test_all_entry_type_values_are_enum_backed():
assert set(ALL_ENTRY_TYPES) == {entry_type.value for entry_type in EntryType}
@pytest.mark.parametrize("entry_type", list(EntryType))
def test_each_entry_type_roundtrip_retrieve_and_filter(tmp_path, entry_type: EntryType):
entry_mgr = _make_entry_manager(tmp_path)
index = _add_entry_for_type(entry_mgr, entry_type)
entry = entry_mgr.retrieve_entry(index)
assert entry is not None
assert entry["type"] == entry_type.value
assert entry["kind"] == entry_type.value
assert entry["label"]
assert "modified_ts" not in entry
entries = entry_mgr.list_entries(
filter_kinds=[entry_type.value], include_archived=True, verbose=False
)
assert len(entries) == 1
listed_index, listed_label, listed_username, listed_url, listed_archived = entries[0]
assert listed_index == index
assert listed_label == entry["label"]
assert listed_archived is False
if entry_type == EntryType.PASSWORD:
assert listed_username == "alice"
assert listed_url == "https://example.com"
assert entry["length"] == 16
assert entry["notes"] == "pw-note"
else:
assert listed_username is None
assert listed_url is None
if entry_type == EntryType.TOTP:
assert entry["deterministic"] is True
assert entry["period"] == 45
assert entry["digits"] == 8
if entry_type == EntryType.SSH:
assert "index" in entry
if entry_type == EntryType.SEED:
assert entry["word_count"] == 12
if entry_type == EntryType.PGP:
assert entry["key_type"] == "ed25519"
assert entry["user_id"] == "[email protected]"
if entry_type == EntryType.NOSTR:
assert "index" in entry
if entry_type == EntryType.KEY_VALUE:
assert entry["key"] == "api_key"
assert entry["value"] == "secret-value"
if entry_type == EntryType.MANAGED_ACCOUNT:
assert entry["word_count"] == 12
assert entry["fingerprint"]
+102
View File
@@ -0,0 +1,102 @@
import io
import pytest
from utils import input_utils
def test_timed_input_no_timeout_reads_stdin(monkeypatch):
monkeypatch.setattr(input_utils.sys, "stdin", io.StringIO(" hello \n"))
assert input_utils.timed_input("Prompt: ", None) == "hello"
def test_timed_input_dispatches_windows(monkeypatch):
monkeypatch.setattr(input_utils.sys, "platform", "win32")
monkeypatch.setattr(
input_utils, "_timed_input_windows", lambda timeout: f"windows:{timeout}"
)
assert input_utils.timed_input("Prompt: ", 1.5) == "windows:1.5"
def test_timed_input_dispatches_posix(monkeypatch):
monkeypatch.setattr(input_utils.sys, "platform", "linux")
monkeypatch.setattr(
input_utils, "_timed_input_posix", lambda timeout: f"posix:{timeout}"
)
assert input_utils.timed_input("Prompt: ", 2.0) == "posix:2.0"
def test_timed_input_posix_fileno_fallback(monkeypatch):
monkeypatch.setattr(input_utils.sys, "stdin", io.StringIO(" via-input \n"))
assert input_utils._timed_input_posix(timeout=5.0) == "via-input"
def test_timed_input_posix_returns_line_when_ready(monkeypatch):
class DummyStdin:
def fileno(self):
return 0
def readline(self):
return "value\n"
dummy_stdin = DummyStdin()
monkeypatch.setattr(input_utils.sys, "stdin", dummy_stdin)
monkeypatch.setattr(
input_utils.select, "select", lambda r, _w, _x, _t: ([dummy_stdin], [], [])
)
assert input_utils._timed_input_posix(timeout=0.1) == "value"
def test_timed_input_posix_raises_timeout(monkeypatch):
class DummyStdin:
def fileno(self):
return 0
dummy_stdin = DummyStdin()
monkeypatch.setattr(input_utils.sys, "stdin", dummy_stdin)
monkeypatch.setattr(
input_utils.select, "select", lambda _r, _w, _x, _t: ([], [], [])
)
with pytest.raises(TimeoutError, match="input timed out"):
input_utils._timed_input_posix(timeout=0.1)
def test_timed_input_windows_fallback_when_msvcrt_missing(monkeypatch):
monkeypatch.setattr(input_utils, "msvcrt", None)
monkeypatch.setattr("builtins.input", lambda: " typed ")
assert input_utils._timed_input_windows(timeout=0.5) == "typed"
def test_timed_input_windows_handles_backspace_and_enter(monkeypatch):
class DummyMSVCRT:
def __init__(self):
self._chars = iter(["a", "\b", "b", "\r"])
def kbhit(self):
return True
def getwche(self):
return next(self._chars)
monkeypatch.setattr(input_utils, "msvcrt", DummyMSVCRT())
monkeypatch.setattr(input_utils.time, "sleep", lambda _s: None)
monkeypatch.setattr(input_utils.time, "time", lambda: 0.0)
assert input_utils._timed_input_windows(timeout=10.0) == "b"
def test_timed_input_windows_raises_timeout(monkeypatch):
class DummyMSVCRT:
def kbhit(self):
return False
def getwche(self):
return ""
timestamps = iter([0.0, 2.0])
monkeypatch.setattr(input_utils, "msvcrt", DummyMSVCRT())
monkeypatch.setattr(input_utils.time, "sleep", lambda _s: None)
monkeypatch.setattr(input_utils.time, "time", lambda: next(timestamps))
with pytest.raises(TimeoutError, match="input timed out"):
input_utils._timed_input_windows(timeout=1.0)
+52
View File
@@ -116,3 +116,55 @@ def test_vault_kdf_migration(tmp_path):
vault.load_index()
new_kdf = mgr.get_file_kdf(vault.index_file)
assert new_kdf.version == KdfConfig().version
def test_derive_seed_key_argon2_uses_configured_time_cost(monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.config_manager = SimpleNamespace(
get_kdf_mode=lambda: "argon2",
get_argon2_time_cost=lambda: 5,
get_kdf_iterations=lambda: 50_000,
)
captured = {}
def fake_argon2(password, cfg):
captured["password"] = password
captured["cfg"] = cfg
return b"derived-argon2-key"
monkeypatch.setattr(
"seedpass.core.manager.derive_key_from_password_argon2", fake_argon2
)
key = pm._derive_seed_key("pw", "fingerprint123")
assert key == b"derived-argon2-key"
assert captured["password"] == "pw"
assert captured["cfg"].params["time_cost"] == 5
assert captured["cfg"].params["memory_cost"] == 64 * 1024
assert captured["cfg"].params["parallelism"] == 8
expected_salt = hashlib.sha256("fingerprint123".encode()).digest()[:16]
assert base64.b64decode(captured["cfg"].salt_b64) == expected_salt
def test_derive_seed_key_pbkdf2_respects_iteration_override(monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.config_manager = SimpleNamespace(
get_kdf_mode=lambda: "pbkdf2",
get_argon2_time_cost=lambda: 2,
get_kdf_iterations=lambda: 777,
)
captured = {}
def fake_pbkdf2(password, fingerprint, iterations):
captured["password"] = password
captured["fingerprint"] = fingerprint
captured["iterations"] = iterations
return b"derived-pbkdf2-key"
monkeypatch.setattr("seedpass.core.manager.derive_key_from_password", fake_pbkdf2)
key = pm._derive_seed_key("pw", "fp", iterations=123)
assert key == b"derived-pbkdf2-key"
assert captured == {"password": "pw", "fingerprint": "fp", "iterations": 123}
+17 -2
View File
@@ -11,7 +11,6 @@ import uuid
import pytest
import base64
import os
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -23,8 +22,18 @@ from seedpass.core.config_manager import ConfigManager
from nostr.client import NostrClient, Kind, KindStandard
def _relays_from_env() -> list[str]:
raw = os.getenv("NOSTR_RELAYS", "")
relays = [value.strip() for value in raw.split(",") if value.strip()]
return relays or ["wss://relay.snort.social"]
@pytest.mark.desktop
@pytest.mark.network
@pytest.mark.skipif(
os.getenv("NOSTR_INDEX_SIZE_E2E") != "1",
reason="Set NOSTR_INDEX_SIZE_E2E=1 to run index size E2E test",
)
def test_nostr_index_size_limits(pytestconfig: pytest.Config):
"""Manually explore maximum index size for Nostr backups."""
seed = (
@@ -36,16 +45,22 @@ def test_nostr_index_size_limits(pytestconfig: pytest.Config):
key = base64.urlsafe_b64encode(os.urandom(32))
enc_mgr = EncryptionManager(key, Path(tmpdir))
with patch.object(enc_mgr, "decrypt_parent_seed", return_value=seed):
account_index = int(uuid.uuid4().int % (2**31 - 1))
client = NostrClient(
enc_mgr,
f"size_test_{uuid.uuid4().hex}",
relays=["wss://relay.snort.social"],
relays=_relays_from_env(),
account_index=account_index,
)
npub = client.key_manager.get_npub()
vault = Vault(enc_mgr, tmpdir)
cfg_mgr = ConfigManager(vault, Path(tmpdir))
backup_mgr = BackupManager(Path(tmpdir), cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
# This test targets payload growth and relay round-trip integrity.
# Disable per-entry local backup/checksum work to keep runtime bounded.
backup_mgr.create_backup = lambda: None
entry_mgr.update_checksum = lambda: None
delay = float(os.getenv("NOSTR_TEST_DELAY", "5"))
max_entries = pytestconfig.getoption("--max-entries")
+222
View File
@@ -14,7 +14,13 @@ import base64
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.encryption import EncryptionManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.manager import EncryptionMode, PasswordManager
from seedpass.core.portable_backup import export_backup, import_backup
from nostr.client import NostrClient
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
@pytest.mark.network
@@ -41,3 +47,219 @@ def test_nostr_publish_and_retrieve():
retrieved = gzip.decompress(b"".join(result[1])) if result else None
client.close_client_pool()
assert retrieved == payload
def _relays_from_env() -> list[str]:
raw = os.getenv("NOSTR_RELAYS", "")
relays = [value.strip() for value in raw.split(",") if value.strip()]
return relays or ["wss://relay.snort.social"]
def _init_pm_for_real_nostr(dir_path: Path, client: NostrClient) -> PasswordManager:
vault, enc_mgr = create_vault(dir_path, TEST_SEED, TEST_PASSWORD)
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.current_fingerprint = "fp"
pm.parent_seed = TEST_SEED
pm.is_dirty = False
pm.secret_mode_enabled = False
pm.state_manager = None
pm.offline_mode = False
pm.notify = lambda *a, **k: None
return pm
@pytest.mark.network
@pytest.mark.skipif(not os.getenv("NOSTR_E2E"), reason="NOSTR_E2E not set")
def test_account_roundtrip_sync_delete_restore_real_relays():
"""Real relay roundtrip:
create account -> add entries -> sync -> delete local -> restore by seed.
"""
relays = _relays_from_env()
account_index = int(uuid.uuid4().int % (2**31 - 1))
fingerprint = f"test_fp_{uuid.uuid4().hex}"
with TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
source_dir = root / "source"
restore_dir = root / "restore"
source_dir.mkdir()
restore_dir.mkdir()
_, source_enc = create_vault(source_dir, TEST_SEED, TEST_PASSWORD)
source_client = NostrClient(
source_enc,
fingerprint,
relays=relays,
parent_seed=TEST_SEED,
account_index=account_index,
)
pm_source = _init_pm_for_real_nostr(source_dir, source_client)
pm_source.entry_manager.add_entry(
"roundtrip-site", 15, username="alice", url="https://example.com"
)
pm_source.entry_manager.add_totp(
"roundtrip-totp",
TEST_SEED,
deterministic=True,
period=45,
digits=8,
)
pm_source.entry_manager.add_key_value("roundtrip-kv", "token", "abc123")
sync = pm_source.sync_vault()
assert sync is not None
index_file = source_dir / "seedpass_entries_db.json.enc"
if index_file.exists():
index_file.unlink()
assert not index_file.exists()
restore_vault, restore_enc = create_vault(restore_dir, TEST_SEED, TEST_PASSWORD)
restore_client = NostrClient(
restore_enc,
fingerprint,
relays=relays,
parent_seed=TEST_SEED,
account_index=account_index,
)
pm_restore = _init_pm_for_real_nostr(restore_dir, restore_client)
restored = False
for attempt in range(6):
restored = pm_restore.attempt_initial_sync()
if restored:
break
time.sleep(1.5 * (attempt + 1))
assert restored is True
pm_restore.entry_manager.clear_cache()
labels = [item[1] for item in pm_restore.entry_manager.list_entries(verbose=False)]
assert sorted(labels) == ["roundtrip-kv", "roundtrip-site", "roundtrip-totp"]
pw = pm_restore.entry_manager.retrieve_entry(0)
totp = pm_restore.entry_manager.retrieve_entry(1)
kv = pm_restore.entry_manager.retrieve_entry(2)
assert pw and pw["kind"] == "password" and pw["username"] == "alice"
assert totp and totp["kind"] == "totp" and totp["deterministic"] is True
assert kv and kv["kind"] == "key_value" and kv["value"] == "abc123"
source_client.close_client_pool()
restore_client.close_client_pool()
@pytest.mark.network
@pytest.mark.skipif(not os.getenv("NOSTR_E2E"), reason="NOSTR_E2E not set")
def test_real_relays_with_portable_export_import_roundtrip():
"""Live relay + portable backup chain in one E2E flow."""
relays = _relays_from_env()
account_index = int(uuid.uuid4().int % (2**31 - 1))
fingerprint = f"test_fp_{uuid.uuid4().hex}"
with TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
source_dir = root / "source"
import_dir = root / "imported"
relay_restore_dir = root / "relay-restore"
source_dir.mkdir()
import_dir.mkdir()
relay_restore_dir.mkdir()
source_vault, source_enc = create_vault(source_dir, TEST_SEED, TEST_PASSWORD)
source_client = NostrClient(
source_enc,
fingerprint,
relays=relays,
parent_seed=TEST_SEED,
account_index=account_index,
)
pm_source = _init_pm_for_real_nostr(source_dir, source_client)
pm_source.entry_manager.add_entry(
"portable-site", 18, username="carol", url="https://portable.example"
)
pm_source.entry_manager.add_totp(
"portable-totp",
TEST_SEED,
deterministic=True,
period=30,
digits=6,
)
pm_source.entry_manager.add_key_value("portable-kv", "api", "xyz789")
first_sync = pm_source.sync_vault()
assert first_sync is not None
export_path = root / "portable-export.json"
path = export_backup(
pm_source.vault,
pm_source.backup_manager,
dest_path=export_path,
parent_seed=TEST_SEED,
encrypt=True,
)
assert path.exists()
_, import_enc = create_vault(import_dir, TEST_SEED, TEST_PASSWORD)
import_client = NostrClient(
import_enc,
fingerprint,
relays=relays,
parent_seed=TEST_SEED,
account_index=account_index,
)
pm_import = _init_pm_for_real_nostr(import_dir, import_client)
import_backup(
pm_import.vault,
pm_import.backup_manager,
path,
parent_seed=TEST_SEED,
)
pm_import.entry_manager.clear_cache()
imported_labels = [
item[1] for item in pm_import.entry_manager.list_entries(verbose=False)
]
assert sorted(imported_labels) == ["portable-kv", "portable-site", "portable-totp"]
resync = pm_import.sync_vault()
assert resync is not None
_, relay_enc = create_vault(relay_restore_dir, TEST_SEED, TEST_PASSWORD)
relay_client = NostrClient(
relay_enc,
fingerprint,
relays=relays,
parent_seed=TEST_SEED,
account_index=account_index,
)
pm_relay_restore = _init_pm_for_real_nostr(relay_restore_dir, relay_client)
restored = False
for attempt in range(6):
restored = pm_relay_restore.attempt_initial_sync()
if restored:
break
time.sleep(1.5 * (attempt + 1))
assert restored is True
pm_relay_restore.entry_manager.clear_cache()
relay_labels = [
item[1] for item in pm_relay_restore.entry_manager.list_entries(verbose=False)
]
assert sorted(relay_labels) == ["portable-kv", "portable-site", "portable-totp"]
source_client.close_client_pool()
import_client.close_client_pool()
relay_client.close_client_pool()
+115
View File
@@ -0,0 +1,115 @@
import queue
from types import SimpleNamespace
from utils import terminal_utils
class _NamedFingerprintManager:
def get_name(self, fingerprint):
return {"parent": "Parent Name", "child": "Child Name"}.get(fingerprint)
def test_format_profile_returns_custom_name():
pm = SimpleNamespace(fingerprint_manager=_NamedFingerprintManager())
assert terminal_utils.format_profile("parent", pm) == "Parent Name (parent)"
def test_format_profile_returns_raw_when_name_missing():
pm = SimpleNamespace(fingerprint_manager=_NamedFingerprintManager())
assert terminal_utils.format_profile("unknown", pm) == "unknown"
def test_clear_and_print_fingerprint_parent_child_breadcrumb(monkeypatch, capsys):
pm = SimpleNamespace(fingerprint_manager=_NamedFingerprintManager())
monkeypatch.setattr(terminal_utils, "clear_screen", lambda: None)
monkeypatch.setattr(terminal_utils, "colored", lambda text, _color: text)
terminal_utils.clear_and_print_fingerprint(
parent_fingerprint="parent",
child_fingerprint="child",
breadcrumb="Settings",
pm=pm,
)
out = capsys.readouterr().out
assert "Seed Profile: Parent Name (parent) > Managed Account > Child Name (child)" in out
assert "> Settings" in out
def test_clear_and_print_profile_chain_with_breadcrumb(monkeypatch, capsys):
pm = SimpleNamespace(fingerprint_manager=_NamedFingerprintManager())
monkeypatch.setattr(terminal_utils, "clear_screen", lambda: None)
monkeypatch.setattr(terminal_utils, "colored", lambda text, _color: text)
terminal_utils.clear_and_print_profile_chain(
["parent", "child"], breadcrumb="Retrieve Entry", pm=pm
)
out = capsys.readouterr().out
assert "Seed Profile: Parent Name (parent) > Managed Account > Child Name (child)" in out
assert "> Retrieve Entry" in out
def test_clear_header_with_notification_invalid_level_falls_back_to_info(
monkeypatch, capsys
):
pm = SimpleNamespace(
fingerprint_manager=_NamedFingerprintManager(),
get_current_notification=lambda: SimpleNamespace(
level="DEBUG", message="sync complete"
),
)
monkeypatch.setattr(terminal_utils, "clear_screen", lambda: None)
monkeypatch.setattr(terminal_utils, "colored", lambda text, _color: text)
monkeypatch.setattr(
terminal_utils, "color_text", lambda message, category: f"[{category}] {message}"
)
terminal_utils.clear_header_with_notification(pm, fingerprint="parent")
out = capsys.readouterr().out
assert "Seed Profile: Parent Name (parent)" in out
assert "[info] sync complete" in out
def test_clear_header_with_notification_handles_queue_empty(monkeypatch, capsys):
pm = SimpleNamespace(
fingerprint_manager=_NamedFingerprintManager(),
get_current_notification=lambda: (_ for _ in ()).throw(queue.Empty()),
)
monkeypatch.setattr(terminal_utils, "clear_screen", lambda: None)
monkeypatch.setattr(terminal_utils, "colored", lambda text, _color: text)
terminal_utils.clear_header_with_notification(pm, fingerprint="parent")
out = capsys.readouterr().out
assert "Seed Profile: Parent Name (parent)" in out
def test_pause_returns_early_when_not_tty(monkeypatch):
monkeypatch.setattr(
terminal_utils.sys,
"stdin",
SimpleNamespace(isatty=lambda: False),
)
called = {"input": False}
monkeypatch.setattr(
"builtins.input",
lambda _msg: called.__setitem__("input", True),
)
terminal_utils.pause("ignored")
assert called["input"] is False
def test_pause_swallows_eoferror(monkeypatch):
monkeypatch.setattr(
terminal_utils.sys,
"stdin",
SimpleNamespace(isatty=lambda: True),
)
monkeypatch.setattr(
"builtins.input",
lambda _msg: (_ for _ in ()).throw(EOFError()),
)
terminal_utils.pause("Press Enter")