Merge pull request #568 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-15 13:09:02 -04:00
committed by GitHub
8 changed files with 943 additions and 456 deletions

View File

@@ -331,6 +331,15 @@ When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to
2. Choose how many seconds to keep passwords on the clipboard.
3. Retrieve an entry and SeedPass will confirm the password was copied.
### Viewing Entry Details
Selecting an item from **List Entries** or **Search Entries** first displays the
entry's metadata such as the label, username, tags and notes. Passwords, seed
phrases and other sensitive fields remain hidden until you choose to reveal
them. When you opt to show the secret, the details view presents the same action
menu as **Retrieve Entry** so you can edit, archive or display QR codes for the
entry.
### Additional Entry Types
SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for:

View File

@@ -156,6 +156,14 @@ $ seedpass entry get "email"
Code: 123456
```
### Viewing Entry Details
Picking an entry from `entry list` or `entry search` displays its metadata first
so you can review the label, username and notes. Sensitive fields are hidden
until you confirm you want to reveal them. After showing the secret, the details
view offers the same actions as `entry get`—edit the entry, archive it or show
QR codes for supported types.
### `vault` Commands
- **`seedpass vault export`** Export the entire vault to an encrypted JSON file.

View File

@@ -437,8 +437,8 @@ class EntryManager:
"""Return the npub and nsec for the specified entry."""
entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
etype = entry.get("type", "").lower() if entry else ""
kind = entry.get("kind", "").lower() if entry else ""
if not entry or (
etype != EntryType.NOSTR.value and kind != EntryType.NOSTR.value
):

View File

@@ -153,6 +153,7 @@ class PasswordManager:
self.profile_stack: list[tuple[str, Path, str]] = []
self.last_unlock_duration: float | None = None
self.verbose_timing: bool = False
self._suppress_entry_actions_menu: bool = False
# Initialize the fingerprint manager first
self.initialize_fingerprint_manager()
@@ -1792,24 +1793,32 @@ class PasswordManager:
pause()
def show_entry_details_by_index(self, index: int) -> None:
"""Display entry details using :meth:`handle_retrieve_entry` for the
given index without prompting for it again."""
original_input = builtins.input
first_call = True
def patched_input(prompt: str = "") -> str:
nonlocal first_call
if first_call:
first_call = False
return str(index)
return original_input(prompt)
"""Display details for entry ``index`` and offer actions."""
try:
builtins.input = patched_input
self.handle_retrieve_entry()
finally:
builtins.input = original_input
entry = self.entry_manager.retrieve_entry(index)
if not entry:
return
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Entry Details",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
self.display_entry_details(index)
if confirm_action("Show sensitive information? (y/N): "):
self.display_sensitive_entry_info(entry, index)
pause()
self._entry_actions_menu(index, entry)
except Exception as e:
logging.error(f"Failed to display entry details: {e}", exc_info=True)
print(colored(f"Error: Failed to display entry details: {e}", "red"))
pause()
def _prompt_toggle_archive(self, entry: dict, index: int) -> None:
"""Prompt the user to archive or restore ``entry`` based on its status."""
@@ -1828,6 +1837,13 @@ class PasswordManager:
self.is_dirty = True
self.last_update = time.time()
def _entry_type_str(self, entry: dict) -> str:
"""Return the entry type as a lowercase string."""
entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if isinstance(entry_type, EntryType):
entry_type = entry_type.value
return str(entry_type).lower()
def _entry_actions_menu(self, index: int, entry: dict) -> None:
"""Provide actions for a retrieved entry."""
while True:
@@ -1840,7 +1856,7 @@ class PasswordManager:
child_fingerprint=child_fp,
)
archived = entry.get("archived", entry.get("blacklisted", False))
entry_type = entry.get("type", EntryType.PASSWORD.value)
entry_type = self._entry_type_str(entry)
print(colored("\n[+] Entry Actions:", "green"))
if archived:
print(colored("U. Unarchive", "cyan"))
@@ -1916,13 +1932,14 @@ class PasswordManager:
self._entry_edit_menu(index, entry)
elif choice == "q":
self._entry_qr_menu(index, entry)
pause()
else:
print(colored("Invalid choice.", "red"))
entry = self.entry_manager.retrieve_entry(index) or entry
def _entry_edit_menu(self, index: int, entry: dict) -> None:
"""Sub-menu for editing common entry fields."""
entry_type = entry.get("type", EntryType.PASSWORD.value)
entry_type = self._entry_type_str(entry)
while True:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
@@ -1982,7 +1999,7 @@ class PasswordManager:
def _entry_qr_menu(self, index: int, entry: dict) -> None:
"""Display QR codes for the given ``entry``."""
entry_type = entry.get("type")
entry_type = self._entry_type_str(entry)
try:
if entry_type in {EntryType.SEED.value, EntryType.MANAGED_ACCOUNT.value}:
@@ -1997,6 +2014,7 @@ class PasswordManager:
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed))
pause()
return
if entry_type == EntryType.NOSTR.value:
@@ -2032,6 +2050,7 @@ class PasswordManager:
TotpManager.print_qr_code(nsec)
else:
print(colored("Invalid choice.", "red"))
pause()
entry = self.entry_manager.retrieve_entry(index) or entry
return
@@ -2040,35 +2059,20 @@ class PasswordManager:
logging.error(f"Error displaying QR menu: {e}", exc_info=True)
print(colored(f"Error: Failed to display QR codes: {e}", "red"))
def handle_retrieve_entry(self) -> None:
"""
Handles retrieving a password from the index by prompting the user for the index number
and displaying the corresponding password and associated details.
"""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > Retrieve Entry",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
index_input = input(
"Enter the index number of the entry to retrieve: "
).strip()
if not index_input.isdigit():
print(colored("Error: Index must be a number.", "red"))
pause()
return
index = int(index_input)
def display_sensitive_entry_info(self, entry: dict, index: int) -> None:
"""Display information for a sensitive entry.
entry = self.entry_manager.retrieve_entry(index)
if not entry:
pause()
return
Parameters
----------
entry: dict
Entry data retrieved from the vault.
index: int
Index of the entry being displayed.
"""
entry_type = entry.get("type", EntryType.PASSWORD.value)
self._suppress_entry_actions_menu = False
entry_type = self._entry_type_str(entry)
if entry_type == EntryType.TOTP.value:
label = entry.get("label", "")
@@ -2124,12 +2128,11 @@ class PasswordManager:
sys.stdout.flush()
if exit_loop:
break
except Exception as e:
except Exception as e: # pragma: no cover - best effort
logging.error(f"Error generating TOTP code: {e}", exc_info=True)
print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.SSH.value:
notes = entry.get("notes", "")
label = entry.get("label", "")
@@ -2163,12 +2166,11 @@ class PasswordManager:
else:
print(colored("Private Key:", "cyan"))
print(color_text(priv_pem, "deterministic"))
except Exception as e:
except Exception as e: # pragma: no cover - best effort
logging.error(f"Error deriving SSH key pair: {e}", exc_info=True)
print(colored(f"Error: Failed to derive SSH keys: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.SEED.value:
notes = entry.get("notes", "")
label = entry.get("label", "")
@@ -2198,7 +2200,6 @@ class PasswordManager:
)
else:
print(color_text(phrase, "deterministic"))
# Removed QR code display prompt and output
if confirm_action("Show derived entropy as hex? (Y/N): "):
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
@@ -2214,12 +2215,11 @@ class PasswordManager:
words_len=words,
)
print(color_text(f"Entropy: {entropy.hex()}", "deterministic"))
except Exception as e:
except Exception as e: # pragma: no cover - best effort
logging.error(f"Error deriving seed phrase: {e}", exc_info=True)
print(colored(f"Error: Failed to derive seed phrase: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.PGP.value:
notes = entry.get("notes", "")
label = entry.get("user_id", "")
@@ -2251,12 +2251,11 @@ class PasswordManager:
)
else:
print(color_text(priv_key, "deterministic"))
except Exception as e:
except Exception as e: # pragma: no cover - best effort
logging.error(f"Error deriving PGP key: {e}", exc_info=True)
print(colored(f"Error: Failed to derive PGP key: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.NOSTR.value:
label = entry.get("label", "")
notes = entry.get("notes", "")
@@ -2277,17 +2276,14 @@ class PasswordManager:
)
else:
print(color_text(f"nsec: {nsec}", "deterministic"))
# QR code display removed for npub and nsec
if notes:
print(colored(f"Notes: {notes}", "cyan"))
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
except Exception as e:
except Exception as e: # pragma: no cover - best effort
logging.error(f"Error deriving Nostr keys: {e}", exc_info=True)
print(colored(f"Error: Failed to derive Nostr keys: {e}", "red"))
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.KEY_VALUE.value:
@@ -2303,8 +2299,7 @@ class PasswordManager:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
print(
colored(
f"Archived Status: {'Archived' if archived else 'Active'}",
"cyan",
f"Archived Status: {'Archived' if archived else 'Active'}", "cyan"
)
)
if self.secret_mode_enabled:
@@ -2335,9 +2330,7 @@ class PasswordManager:
if show == "y":
for f_label, f_value in hidden_fields:
if self.secret_mode_enabled:
copy_to_clipboard(
f_value, self.clipboard_clear_delay
)
copy_to_clipboard(f_value, self.clipboard_clear_delay)
print(
colored(
f"[+] {f_label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
@@ -2346,9 +2339,8 @@ class PasswordManager:
)
else:
print(colored(f" {f_label}: {f_value}", "cyan"))
self._entry_actions_menu(index, entry)
pause()
return
if entry_type == EntryType.MANAGED_ACCOUNT.value:
label = entry.get("label", "")
notes = entry.get("notes", "")
@@ -2364,8 +2356,7 @@ class PasswordManager:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
print(
colored(
f"Archived Status: {'Archived' if archived else 'Active'}",
"cyan",
f"Archived Status: {'Archived' if archived else 'Active'}", "cyan"
)
)
action = (
@@ -2389,17 +2380,14 @@ class PasswordManager:
)
else:
print(color_text(seed, "deterministic"))
# QR code display removed for managed account seed
self._entry_actions_menu(index, entry)
pause()
return
if action == "l":
self._suppress_entry_actions_menu = True
self.load_managed_account(index)
return
self._entry_actions_menu(index, entry)
pause()
return
# Default: PASSWORD
website_name = entry.get("label", entry.get("website"))
length = entry.get("length")
username = entry.get("username")
@@ -2466,15 +2454,11 @@ class PasswordManager:
else:
print(colored(f" {label}: {value}", "cyan"))
if hidden_fields:
show = (
input("Reveal hidden fields? (y/N): ").strip().lower()
)
show = input("Reveal hidden fields? (y/N): ").strip().lower()
if show == "y":
for label, value in hidden_fields:
if self.secret_mode_enabled:
copy_to_clipboard(
value, self.clipboard_clear_delay
)
copy_to_clipboard(value, self.clipboard_clear_delay)
print(
colored(
f"[+] {label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
@@ -2485,8 +2469,37 @@ class PasswordManager:
print(colored(f" {label}: {value}", "cyan"))
else:
print(colored("Error: Failed to retrieve the password.", "red"))
return
def handle_retrieve_entry(self) -> None:
"""Prompt for an index and display the corresponding entry."""
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > Retrieve Entry",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
index_input = input(
"Enter the index number of the entry to retrieve: "
).strip()
if not index_input.isdigit():
print(colored("Error: Index must be a number.", "red"))
pause()
return
index = int(index_input)
entry = self.entry_manager.retrieve_entry(index)
if not entry:
pause()
return
self.display_sensitive_entry_info(entry, index)
self._entry_actions_menu(index, entry)
pause()
return
except Exception as e:
logging.error(f"Error during password retrieval: {e}", exc_info=True)
print(colored(f"Error: Failed to retrieve password: {e}", "red"))
@@ -2519,7 +2532,7 @@ class PasswordManager:
if not entry:
return
entry_type = entry.get("type", EntryType.PASSWORD.value)
entry_type = self._entry_type_str(entry)
if entry_type == EntryType.TOTP.value:
label = entry.get("label", "")
@@ -2912,7 +2925,7 @@ class PasswordManager:
if not entry:
return
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
etype = self._entry_type_str(entry)
print(color_text(f"Index: {index}", "index"))
if etype == EntryType.TOTP.value:
print(color_text(f" Label: {entry.get('label', '')}", "index"))
@@ -2934,9 +2947,13 @@ class PasswordManager:
elif etype == EntryType.SEED.value:
print(color_text(" Type: Seed Phrase", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(color_text(f" Words: {entry.get('words', 24)}", "index"))
words = entry.get("word_count", entry.get("words", 24))
print(color_text(f" Words: {words}", "index"))
print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
color_text(
f" Derivation Index: {entry.get('index', index)}",
"index",
)
)
notes = entry.get("notes", "")
if notes:
@@ -2950,6 +2967,12 @@ class PasswordManager:
print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
pub_label = entry.get("public_key_label", "")
if pub_label:
print(color_text(f" Public Key Label: {pub_label}", "index"))
ssh_fingerprint = entry.get("fingerprint", "")
if ssh_fingerprint:
print(color_text(f" Fingerprint: {ssh_fingerprint}", "index"))
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
@@ -2968,6 +2991,14 @@ class PasswordManager:
print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
try:
_priv, pgp_fp = self.entry_manager.get_pgp_key(index, self.parent_seed)
if pgp_fp:
print(color_text(f" Fingerprint: {pgp_fp}", "index"))
except Exception as pgp_err: # pragma: no cover - best effort logging
logging.error(
f"Failed to derive PGP fingerprint: {pgp_err}", exc_info=True
)
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
@@ -2986,6 +3017,37 @@ class PasswordManager:
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
elif etype == EntryType.KEY_VALUE.value:
print(color_text(" Type: Key/Value", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(color_text(f" Value: {entry.get('value', '')}", "index"))
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
blacklisted = entry.get("archived", entry.get("blacklisted", False))
print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index"))
elif etype == EntryType.MANAGED_ACCOUNT.value:
print(color_text(" Type: Managed Account", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
words = entry.get("word_count", entry.get("words", 24))
print(color_text(f" Words: {words}", "index"))
print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
fingerprint = entry.get("fingerprint", "")
if fingerprint:
print(color_text(f" Fingerprint: {fingerprint}", "index"))
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
blacklisted = entry.get("archived", entry.get("blacklisted", False))
print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index"))
else:
website = entry.get("label", entry.get("website", ""))
username = entry.get("username", "")
@@ -3218,7 +3280,9 @@ class PasswordManager:
entries = data.get("entries", {})
totp_list: list[tuple[str, int, int, bool]] = []
for idx_str, entry in entries.items():
if entry.get("type") == EntryType.TOTP.value and not entry.get(
if self._entry_type_str(
entry
) == EntryType.TOTP.value and not entry.get(
"archived", entry.get("blacklisted", False)
):
label = entry.get("label", "")
@@ -3514,7 +3578,7 @@ class PasswordManager:
totp_entries = []
for entry in entries.values():
if entry.get("type") == EntryType.TOTP.value:
if self._entry_type_str(entry) == EntryType.TOTP.value:
label = entry.get("label", "")
period = int(entry.get("period", 30))
digits = int(entry.get("digits", 6))
@@ -3810,7 +3874,7 @@ class PasswordManager:
entries = data.get("entries", {})
counts: dict[str, int] = {etype.value: 0 for etype in EntryType}
for entry in entries.values():
etype = entry.get("type", EntryType.PASSWORD.value)
etype = self._entry_type_str(entry)
counts[etype] = counts.get(etype, 0) + 1
stats["entries"] = counts
stats["total_entries"] = len(entries)

View File

@@ -2,6 +2,8 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
import sys
@@ -11,6 +13,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.entry_types import EntryType
from password_manager.config_manager import ConfigManager
@@ -80,12 +83,314 @@ def test_list_entries_show_details(monkeypatch, capsys):
lambda *a, **k: "b",
)
inputs = iter(["1", "0", "n"])
inputs = iter(["1", "0"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_list_entries()
out = capsys.readouterr().out
assert "Retrieved 2FA Code" in out
assert "123456" in out
assert "Label: Example" in out
assert "Period: 30s" in out
assert "API" in out
assert "acct" in out
def test_show_entry_details_by_index(monkeypatch):
"""Ensure entry details screen triggers expected calls."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_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.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
index = entry_mgr.add_entry("example.com", 12)
header_calls = []
monkeypatch.setattr(
"password_manager.manager.clear_header_with_notification",
lambda *a, **k: header_calls.append(True),
)
call_order = []
monkeypatch.setattr(
pm,
"display_entry_details",
lambda *a, **k: call_order.append("display"),
)
monkeypatch.setattr(
pm,
"_entry_actions_menu",
lambda *a, **k: call_order.append("actions"),
)
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: False
)
pm.show_entry_details_by_index(index)
assert len(header_calls) == 1
assert call_order == ["display", "actions"]
def _setup_manager(tmp_path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_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.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
return pm, entry_mgr
def _detail_common(monkeypatch, pm):
monkeypatch.setattr(
"password_manager.manager.clear_header_with_notification",
lambda *a, **k: None,
)
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: False
)
called = []
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: called.append(True))
return called
def test_show_seed_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: Seed Phrase" in out
assert "Label: seed" in out
assert "Words: 12" in out
assert f"Derivation Index: {idx}" in out
assert called == [True]
def test_show_ssh_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_ssh_key("ssh", TEST_SEED)
data = entry_mgr._load_index(force_reload=True)
data["entries"][str(idx)]["public_key_label"] = "server"
data["entries"][str(idx)]["fingerprint"] = "abc123"
entry_mgr._save_index(data)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: SSH Key" in out
assert "Label: ssh" in out
assert f"Derivation Index: {idx}" in out
assert "server" in out
assert "abc123" in out
assert called == [True]
def test_show_pgp_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
_k, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: PGP Key" in out
assert "Label: pgp" in out
assert "Key Type: ed25519" in out
assert "User ID: test" in out
assert f"Derivation Index: {idx}" in out
assert fp in out
assert called == [True]
def test_show_nostr_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_nostr_key("nostr")
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: Nostr Key" in out
assert "Label: nostr" in out
assert f"Derivation Index: {idx}" in out
assert called == [True]
def test_show_managed_account_entry_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
fp = entry_mgr.retrieve_entry(idx).get("fingerprint")
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert "Type: Managed Account" in out
assert "Label: acct" in out
assert f"Derivation Index: {idx}" in out
assert "Words: 12" in out
assert fp in out
assert called == [True]
@pytest.mark.parametrize(
"entry_type",
[
"password",
"seed",
"ssh",
"pgp",
"nostr",
"totp",
"key_value",
"managed_account",
],
)
def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
"""Ensure sensitive details are displayed for each entry type."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: True
)
monkeypatch.setattr(
"password_manager.manager.copy_to_clipboard", lambda *a, **k: None
)
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr(
"password_manager.manager.TotpManager.print_qr_code", lambda *a, **k: None
)
monkeypatch.setattr(
"password_manager.manager.clear_header_with_notification",
lambda *a, **k: None,
)
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
input_val = "r" if entry_type == "managed_account" else ""
monkeypatch.setattr("builtins.input", lambda *a, **k: input_val)
called = []
monkeypatch.setattr(
pm, "_entry_actions_menu", lambda *a, **k: called.append(True)
)
if entry_type == "password":
idx = entry_mgr.add_entry("example", 8)
expected = "pw123"
elif entry_type == "seed":
idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12)
expected = entry_mgr.get_seed_phrase(idx, TEST_SEED)
elif entry_type == "ssh":
idx = entry_mgr.add_ssh_key("ssh", TEST_SEED)
priv, pub = entry_mgr.get_ssh_key_pair(idx, TEST_SEED)
expected = priv
extra = pub
elif entry_type == "pgp":
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
priv, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
expected = priv
extra = fp
elif entry_type == "nostr":
idx = entry_mgr.add_nostr_key("nostr")
_npub, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
expected = nsec
elif entry_type == "totp":
entry_mgr.add_totp("Example", TEST_SEED)
idx = 0
monkeypatch.setattr(
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
)
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
)
expected = "123456"
elif entry_type == "key_value":
idx = entry_mgr.add_key_value("API", "abc")
expected = "abc"
else: # managed_account
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
expected = entry_mgr.get_managed_account_seed(idx, TEST_SEED)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert expected in out
if entry_type in {"ssh", "pgp"}:
assert extra in out
assert called == [True]
@pytest.mark.parametrize(
"entry_type", [EntryType.PASSWORD, EntryType.TOTP, EntryType.KEY_VALUE]
)
def test_show_entry_details_with_enum_type(monkeypatch, capsys, entry_type):
"""Entries storing an EntryType enum should display correctly."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
if entry_type == EntryType.PASSWORD:
idx = entry_mgr.add_entry("example.com", 8)
expect = "example.com"
elif entry_type == EntryType.TOTP:
entry_mgr.add_totp("Example", TEST_SEED)
idx = 0
monkeypatch.setattr(
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
)
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
)
expect = "Label: Example"
else: # KEY_VALUE
idx = entry_mgr.add_key_value("API", "abc")
expect = "API"
data = entry_mgr._load_index(force_reload=True)
data["entries"][str(idx)]["type"] = entry_type
entry_mgr._save_index(data)
called = _detail_common(monkeypatch, pm)
pm.show_entry_details_by_index(idx)
out = capsys.readouterr().out
assert expect in out
assert called == [True]

View File

@@ -41,11 +41,11 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys):
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
inputs = iter(["Example", "0", "n", ""])
inputs = iter(["Example", "0"])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_search_entries()
out = capsys.readouterr().out
assert "0. Example" in out
assert "Retrieved 2FA Code" in out
assert "123456" in out
assert "Label: Example" in out
assert "Period: 30s" in out

View File

@@ -93,3 +93,46 @@ def test_show_private_key_qr(monkeypatch, capsys):
out = capsys.readouterr().out
assert called == [nsec]
assert color_text(f"nsec: {nsec}", "deterministic") in out
def test_qr_menu_case_insensitive(monkeypatch):
"""QR menu should appear even if entry type is uppercase."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_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.parent_seed = TEST_SEED
pm.nostr_client = FakeNostrClient()
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.secret_mode_enabled = False
idx = entry_mgr.add_nostr_key("main")
npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
# Modify index to use uppercase type/kind
data = enc_mgr.load_json_data(entry_mgr.index_file)
data["entries"][str(idx)]["type"] = "NOSTR"
data["entries"][str(idx)]["kind"] = "NOSTR"
enc_mgr.save_json_data(data, entry_mgr.index_file)
entry_mgr._index_cache = None
inputs = iter([str(idx), "q", "p", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
called = []
monkeypatch.setattr(
"password_manager.manager.TotpManager.print_qr_code",
lambda data: called.append(data),
)
pm.handle_retrieve_entry()
assert called == [f"nostr:{npub}"]

View File

@@ -0,0 +1,58 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager
import pytest
@pytest.mark.parametrize(
"adder,needs_confirm",
[
(lambda mgr: mgr.add_seed("seed", TEST_SEED), True),
(lambda mgr: mgr.add_pgp_key("pgp", TEST_SEED, user_id="test"), True),
(lambda mgr: mgr.add_ssh_key("ssh", TEST_SEED), True),
(lambda mgr: mgr.add_nostr_key("nostr"), False),
],
)
def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_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.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
index = adder(entry_mgr)
pause_calls = []
monkeypatch.setattr(
"password_manager.manager.pause", lambda *a, **k: pause_calls.append(True)
)
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: None)
monkeypatch.setattr("builtins.input", lambda *a, **k: str(index))
if needs_confirm:
monkeypatch.setattr(
"password_manager.manager.confirm_action", lambda *a, **k: True
)
pm.handle_retrieve_entry()
assert len(pause_calls) == 1