diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 18027da..f3bdf48 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -478,7 +478,7 @@ def get_totp_codes( _require_password(request, password) pm = _get_pm(request) entries = pm.entry_manager.list_entries( - filter_kind=EntryType.TOTP.value, include_archived=False + filter_kinds=[EntryType.TOTP.value], include_archived=False ) codes = [] for idx, label, _u, _url, _arch in entries: diff --git a/src/seedpass/cli/entry.py b/src/seedpass/cli/entry.py index f3a5b7e..22f4e11 100644 --- a/src/seedpass/cli/entry.py +++ b/src/seedpass/cli/entry.py @@ -6,8 +6,10 @@ from pathlib import Path from typing import List, Optional import typer +import click from .common import _get_entry_service, EntryType +from seedpass.core.entry_types import ALL_ENTRY_TYPES from utils.clipboard import ClipboardUnavailableError @@ -20,13 +22,20 @@ def entry_list( sort: str = typer.Option( "index", "--sort", help="Sort by 'index', 'label', or 'updated'" ), - kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"), + kind: Optional[str] = typer.Option( + None, + "--kind", + help="Filter by entry type", + click_type=click.Choice(ALL_ENTRY_TYPES), + ), archived: bool = typer.Option(False, "--archived", help="Include archived"), ) -> None: """List entries in the vault.""" service = _get_entry_service(ctx) entries = service.list_entries( - sort_by=sort, filter_kind=kind, include_archived=archived + sort_by=sort, + filter_kinds=[kind] if kind else None, + include_archived=archived, ) for idx, label, username, url, is_archived in entries: line = f"{idx}: {label}" @@ -43,16 +52,17 @@ def entry_list( def entry_search( ctx: typer.Context, query: str, - kind: List[str] = typer.Option( + kinds: List[str] = typer.Option( None, "--kind", "-k", help="Filter by entry kinds (can be repeated)", + click_type=click.Choice(ALL_ENTRY_TYPES), ), ) -> None: """Search entries.""" service = _get_entry_service(ctx) - kinds = list(kind) if kind else None + kinds = list(kinds) if kinds else None results = service.search_entries(query, kinds=kinds) if not results: typer.echo("No matching entries found") diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 3198241..a8ca1e2 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -265,13 +265,13 @@ class EntryService: def list_entries( self, sort_by: str = "index", - filter_kind: str | None = None, + filter_kinds: list[str] | None = None, include_archived: bool = False, ): with self._lock: return self._manager.entry_manager.list_entries( sort_by=sort_by, - filter_kind=filter_kind, + filter_kinds=filter_kinds, include_archived=include_archived, ) diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index fa81591..5c4fadb 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -33,7 +33,7 @@ from pathlib import Path from termcolor import colored from .migrations import LATEST_VERSION -from .entry_types import EntryType +from .entry_types import EntryType, ALL_ENTRY_TYPES from .totp import TotpManager from utils.fingerprint import generate_fingerprint from utils.checksum import canonical_json_dumps @@ -1076,7 +1076,7 @@ class EntryManager: def list_entries( self, sort_by: str = "index", - filter_kind: str | None = None, + filter_kinds: list[str] | None = None, *, include_archived: bool = False, verbose: bool = True, @@ -1088,8 +1088,9 @@ class EntryManager: sort_by: Field to sort by. Supported values are ``"index"``, ``"label"`` and ``"updated"``. - filter_kind: - Optional entry kind to restrict the results. + filter_kinds: + Optional list of entry kinds to restrict the results. Defaults to + ``ALL_ENTRY_TYPES``. Archived entries are omitted unless ``include_archived`` is ``True``. """ @@ -1118,12 +1119,14 @@ class EntryManager: sorted_items = sorted(entries_data.items(), key=sort_key) + if filter_kinds is None: + filter_kinds = ALL_ENTRY_TYPES + filtered_items: List[Tuple[int, Dict[str, Any]]] = [] for idx_str, entry in sorted_items: if ( - filter_kind is not None - and entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - != filter_kind + entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + not in filter_kinds ): continue if not include_archived and entry.get( @@ -1371,7 +1374,7 @@ class EntryManager: def list_all_entries( self, sort_by: str = "index", - filter_kind: str | None = None, + filter_kinds: list[str] | None = None, *, include_archived: bool = False, ) -> None: @@ -1379,7 +1382,7 @@ class EntryManager: try: entries = self.list_entries( sort_by=sort_by, - filter_kind=filter_kind, + filter_kinds=filter_kinds, include_archived=include_archived, ) if not entries: @@ -1403,7 +1406,7 @@ class EntryManager: def get_entry_summaries( self, - filter_kind: str | None = None, + filter_kinds: list[str] | None = None, *, include_archived: bool = False, ) -> list[tuple[int, str, str]]: @@ -1412,10 +1415,13 @@ class EntryManager: data = self._load_index() entries_data = data.get("entries", {}) + if filter_kinds is None: + filter_kinds = ALL_ENTRY_TYPES + summaries: list[tuple[int, str, str]] = [] for idx_str, entry in entries_data.items(): etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) - if filter_kind and etype != filter_kind: + if etype not in filter_kinds: continue if not include_archived and entry.get( "archived", entry.get("blacklisted", False) diff --git a/src/seedpass/core/entry_types.py b/src/seedpass/core/entry_types.py index a11643a..0babc4f 100644 --- a/src/seedpass/core/entry_types.py +++ b/src/seedpass/core/entry_types.py @@ -15,3 +15,7 @@ class EntryType(str, Enum): NOSTR = "nostr" KEY_VALUE = "key_value" MANAGED_ACCOUNT = "managed_account" + + +# List of all entry type values for convenience +ALL_ENTRY_TYPES = [e.value for e in EntryType] diff --git a/src/seedpass/core/menu_handler.py b/src/seedpass/core/menu_handler.py index 23e3ea9..b0a2471 100644 --- a/src/seedpass/core/menu_handler.py +++ b/src/seedpass/core/menu_handler.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from termcolor import colored -from .entry_types import EntryType +from .entry_types import EntryType, ALL_ENTRY_TYPES import seedpass.core.manager as manager_module from utils.color_scheme import color_text from utils.terminal_utils import clear_header_with_notification @@ -36,33 +36,16 @@ class MenuHandler: ) print(color_text("\nList Entries:", "menu")) print(color_text("1. All", "menu")) - print(color_text("2. Passwords", "menu")) - print(color_text("3. 2FA (TOTP)", "menu")) - print(color_text("4. SSH Key", "menu")) - print(color_text("5. Seed Phrase", "menu")) - print(color_text("6. Nostr Key Pair", "menu")) - print(color_text("7. PGP", "menu")) - print(color_text("8. Key/Value", "menu")) - print(color_text("9. Managed Account", "menu")) + option_map: dict[str, str] = {} + for i, etype in enumerate(ALL_ENTRY_TYPES, start=2): + label = etype.replace("_", " ").title() + print(color_text(f"{i}. {label}", "menu")) + option_map[str(i)] = etype choice = input("Select entry type or press Enter to go back: ").strip() if choice == "1": - filter_kind = None - elif choice == "2": - filter_kind = EntryType.PASSWORD.value - elif choice == "3": - filter_kind = EntryType.TOTP.value - elif choice == "4": - filter_kind = EntryType.SSH.value - elif choice == "5": - filter_kind = EntryType.SEED.value - elif choice == "6": - filter_kind = EntryType.NOSTR.value - elif choice == "7": - filter_kind = EntryType.PGP.value - elif choice == "8": - filter_kind = EntryType.KEY_VALUE.value - elif choice == "9": - filter_kind = EntryType.MANAGED_ACCOUNT.value + filter_kinds = None + elif choice in option_map: + filter_kinds = [option_map[choice]] elif not choice: return else: @@ -71,7 +54,7 @@ class MenuHandler: while True: summaries = pm.entry_manager.get_entry_summaries( - filter_kind, include_archived=False + filter_kinds, include_archived=False ) if not summaries: break @@ -85,7 +68,7 @@ class MenuHandler: ) print(colored("\n[+] Entries:\n", "green")) for idx, etype, label in summaries: - if filter_kind is None: + if filter_kinds is None: display_type = etype.capitalize() print(colored(f"{idx}. {display_type} - {label}", "cyan")) else: diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index e11c23c..9458211 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -393,7 +393,7 @@ class TotpViewerWindow(toga.Window): def refresh_codes(self) -> None: self.table.data = [] for idx, label, *_rest in self.entries.list_entries( - filter_kind=EntryType.TOTP.value + filter_kinds=[EntryType.TOTP.value] ): entry = self.entries.retrieve_entry(idx) code = self.entries.get_totp_code(idx) diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 99bdb15..2d0b1c7 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -16,7 +16,7 @@ from seedpass.core.entry_types import EntryType class DummyPM: def __init__(self): self.entry_manager = SimpleNamespace( - list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [ + list_entries=lambda sort_by="index", filter_kinds=None, include_archived=False: [ (1, "Label", "user", "url", False) ], search_entries=lambda q, kinds=None: [ diff --git a/src/tests/test_gui_features.py b/src/tests/test_gui_features.py index 90f0279..f7f8b63 100644 --- a/src/tests/test_gui_features.py +++ b/src/tests/test_gui_features.py @@ -30,8 +30,8 @@ class DummyEntries: self.data = [(1, "Example", None, None, False)] self.code = "111111" - def list_entries(self, sort_by="index", filter_kind=None, include_archived=False): - if filter_kind: + def list_entries(self, sort_by="index", filter_kinds=None, include_archived=False): + if filter_kinds: return [(idx, label, None, None, False) for idx, label, *_ in self.data] return self.data diff --git a/src/tests/test_gui_sync.py b/src/tests/test_gui_sync.py index 6ef1ed2..cf1aa95 100644 --- a/src/tests/test_gui_sync.py +++ b/src/tests/test_gui_sync.py @@ -9,7 +9,7 @@ from seedpass_gui.app import MainWindow class DummyEntries: - def list_entries(self, sort_by="index", filter_kind=None, include_archived=False): + def list_entries(self, sort_by="index", filter_kinds=None, include_archived=False): return [] def search_entries(self, q): diff --git a/src/tests/test_list_entries_all_types.py b/src/tests/test_list_entries_all_types.py new file mode 100644 index 0000000..2628869 --- /dev/null +++ b/src/tests/test_list_entries_all_types.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +from typer.testing import CliRunner + +from seedpass.cli import app as cli_app +from seedpass.cli import entry as entry_cli +from helpers import create_vault, TEST_SEED, TEST_PASSWORD +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 PasswordManager, EncryptionMode + + +def _setup_manager(tmp_path: Path) -> tuple[PasswordManager, EntryManager]: + 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 _create_all_entries(em: EntryManager) -> None: + em.add_entry("pw", 8) + em.add_totp("totp", TEST_SEED) + em.add_ssh_key("ssh", TEST_SEED) + em.add_seed("seed", TEST_SEED, words_num=12) + em.add_nostr_key("nostr", TEST_SEED) + em.add_pgp_key("pgp", TEST_SEED) + em.add_key_value("kv", "k", "v") + em.add_managed_account("acct", TEST_SEED) + + +def test_cli_list_all_types(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, em = _setup_manager(tmp_path) + _create_all_entries(em) + + def fake_get_entry_service(_ctx): + return SimpleNamespace( + list_entries=lambda sort_by, filter_kinds, include_archived: pm.entry_manager.list_entries( + sort_by=sort_by, + filter_kinds=filter_kinds, + include_archived=include_archived, + ) + ) + + monkeypatch.setattr(entry_cli, "_get_entry_service", fake_get_entry_service) + + runner = CliRunner() + result = runner.invoke(cli_app, ["entry", "list"]) + assert result.exit_code == 0 + out = result.stdout + for label in ["pw", "totp", "ssh", "seed", "nostr", "pgp", "kv", "acct"]: + assert label in out + + +def test_menu_list_all_types(monkeypatch, capsys): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, em = _setup_manager(tmp_path) + _create_all_entries(em) + + inputs = iter(["1", "", ""]) # choose All then exit + monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) + + pm.handle_list_entries() + out = capsys.readouterr().out + for label in ["pw", "totp", "ssh", "seed", "nostr", "pgp", "kv", "acct"]: + assert label in out diff --git a/src/tests/test_list_entries_sort_filter.py b/src/tests/test_list_entries_sort_filter.py index 4361008..476836f 100644 --- a/src/tests/test_list_entries_sort_filter.py +++ b/src/tests/test_list_entries_sort_filter.py @@ -57,5 +57,5 @@ def test_filter_by_type(): em = setup_entry_manager(tmp_path) em.add_entry("site", 8, "user") em.add_totp("Example", TEST_SEED) - result = em.list_entries(filter_kind=EntryType.TOTP.value) + result = em.list_entries(filter_kinds=[EntryType.TOTP.value]) assert result == [(1, "Example", None, None, False)] diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 51feac5..4f67202 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -18,8 +18,8 @@ runner = CliRunner() def test_entry_list(monkeypatch): called = {} - def list_entries(sort_by="index", filter_kind=None, include_archived=False): - called["args"] = (sort_by, filter_kind, include_archived) + def list_entries(sort_by="index", filter_kinds=None, include_archived=False): + called["args"] = (sort_by, filter_kinds, include_archived) return [(0, "Site", "user", "", False)] pm = SimpleNamespace(