From 9a22d537a597830395640150f5c1003b53646a8f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:09:13 -0400 Subject: [PATCH] Add CLI subcommands and tests --- src/main.py | 146 ++++++++++++++++++++++-------- src/requirements.txt | 1 + src/tests/test_cli_subcommands.py | 66 ++++++++++++++ 3 files changed, 175 insertions(+), 38 deletions(-) create mode 100644 src/tests/test_cli_subcommands.py diff --git a/src/main.py b/src/main.py index 597e2c6..601400a 100644 --- a/src/main.py +++ b/src/main.py @@ -16,10 +16,12 @@ import traceback from password_manager.manager import PasswordManager from nostr.client import NostrClient +from password_manager.entry_types import EntryType from constants import INACTIVITY_TIMEOUT, initialize_app from utils.password_prompt import PasswordPromptError from utils import timed_input from local_bip85.bip85 import Bip85Error +import pyperclip colorama_init() @@ -233,6 +235,22 @@ def handle_display_stats(password_manager: PasswordManager) -> None: print(colored(f"Error: Failed to display stats: {e}", "red")) +def print_matches(matches: list[tuple[int, str, str | None, str | None, bool]]) -> None: + """Print a list of search matches.""" + print(colored("\n[+] Matches:\n", "green")) + for entry in matches: + idx, website, username, url, blacklisted = entry + print(colored(f"Index: {idx}", "cyan")) + if website: + print(colored(f" Website: {website}", "cyan")) + if username: + print(colored(f" Username: {username}", "cyan")) + if url: + print(colored(f" URL: {url}", "cyan")) + print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan")) + print("-" * 40) + + def handle_post_to_nostr( password_manager: PasswordManager, alt_summary: str | None = None ): @@ -702,15 +720,14 @@ def display_menu( print(colored("Invalid choice. Please select a valid option.", "red")) -if __name__ == "__main__": - # Configure logging with both file and console handlers +def main(argv: list[str] | None = None) -> int: + """Entry point for the SeedPass CLI.""" configure_logging() initialize_app() logger = logging.getLogger(__name__) logger.info("Starting SeedPass Password Manager") - # Load config from disk and parse command-line arguments - cfg = load_global_config() + load_global_config() parser = argparse.ArgumentParser() sub = parser.add_subparsers(dest="command") @@ -720,48 +737,96 @@ if __name__ == "__main__": imp = sub.add_parser("import") imp.add_argument("--file") - args = parser.parse_args() + search_p = sub.add_parser("search") + search_p.add_argument("query") + + get_p = sub.add_parser("get") + get_p.add_argument("query") + + totp_p = sub.add_parser("totp") + totp_p.add_argument("query") + + args = parser.parse_args(argv) - # Initialize PasswordManager and proceed with application logic try: password_manager = PasswordManager() logger.info("PasswordManager initialized successfully.") except (PasswordPromptError, Bip85Error) as e: logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True) print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) - sys.exit(1) + return 1 except Exception as e: logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True) print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) - sys.exit(1) + return 1 if args.command == "export": password_manager.handle_export_database(Path(args.file)) - sys.exit(0) - elif args.command == "import": + return 0 + if args.command == "import": password_manager.handle_import_database(Path(args.file)) - sys.exit(0) + return 0 + if args.command == "search": + matches = password_manager.entry_manager.search_entries(args.query) + if matches: + print_matches(matches) + else: + print(colored("No matching entries found.", "yellow")) + return 0 + if args.command == "get": + matches = password_manager.entry_manager.search_entries(args.query) + if len(matches) != 1: + if not matches: + print(colored("No matching entries found.", "yellow")) + else: + print_matches(matches) + return 1 + idx = matches[0][0] + entry = password_manager.entry_manager.retrieve_entry(idx) + if entry.get("type", EntryType.PASSWORD.value) != EntryType.PASSWORD.value: + print(colored("Entry is not a password entry.", "red")) + return 1 + length = int(entry.get("length", 0)) + pw = password_manager.password_generator.generate_password(length, idx) + print(pw) + return 0 + if args.command == "totp": + matches = password_manager.entry_manager.search_entries(args.query) + if len(matches) != 1: + if not matches: + print(colored("No matching entries found.", "yellow")) + else: + print_matches(matches) + return 1 + idx = matches[0][0] + entry = password_manager.entry_manager.retrieve_entry(idx) + if entry.get("type") != EntryType.TOTP.value: + print(colored("Entry is not a TOTP entry.", "red")) + return 1 + code = password_manager.entry_manager.get_totp_code( + idx, password_manager.parent_seed + ) + print(code) + try: + pyperclip.copy(code) + except Exception as exc: + logging.warning(f"Clipboard copy failed: {exc}") + return 0 - # Register signal handlers for graceful shutdown - def signal_handler(sig, frame): - """ - Handles termination signals to gracefully shutdown the NostrClient. - """ + def signal_handler(sig, _frame): print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow")) logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.") try: - password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool + password_manager.nostr_client.close_client_pool() logging.info("NostrClient closed successfully.") - except Exception as e: - logging.error(f"Error during shutdown: {e}") - print(colored(f"Error during shutdown: {e}", "red")) + except Exception as exc: + logging.error(f"Error during shutdown: {exc}") + print(colored(f"Error during shutdown: {exc}", "red")) sys.exit(0) - # Register the signal handlers - signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C - signal.signal(signal.SIGTERM, signal_handler) # Handle termination signals + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) - # Display the interactive menu to the user try: display_menu( password_manager, inactivity_timeout=password_manager.inactivity_timeout @@ -770,29 +835,34 @@ if __name__ == "__main__": logger.info("Program terminated by user via KeyboardInterrupt.") print(colored("\nProgram terminated by user.", "yellow")) try: - password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool + password_manager.nostr_client.close_client_pool() logging.info("NostrClient closed successfully.") - except Exception as e: - logging.error(f"Error during shutdown: {e}") - print(colored(f"Error during shutdown: {e}", "red")) - sys.exit(0) + except Exception as exc: + logging.error(f"Error during shutdown: {exc}") + print(colored(f"Error during shutdown: {exc}", "red")) + return 0 except (PasswordPromptError, Bip85Error) as e: logger.error(f"A user-related error occurred: {e}", exc_info=True) print(colored(f"Error: {e}", "red")) try: password_manager.nostr_client.close_client_pool() logging.info("NostrClient closed successfully.") - except Exception as close_error: - logging.error(f"Error during shutdown: {close_error}") - print(colored(f"Error during shutdown: {close_error}", "red")) - sys.exit(1) + except Exception as exc: + logging.error(f"Error during shutdown: {exc}") + print(colored(f"Error during shutdown: {exc}", "red")) + return 1 except Exception as e: logger.error(f"An unexpected error occurred: {e}", exc_info=True) print(colored(f"Error: An unexpected error occurred: {e}", "red")) try: - password_manager.nostr_client.close_client_pool() # Attempt to close the ClientPool + password_manager.nostr_client.close_client_pool() logging.info("NostrClient closed successfully.") - except Exception as close_error: - logging.error(f"Error during shutdown: {close_error}") - print(colored(f"Error during shutdown: {close_error}", "red")) - sys.exit(1) + except Exception as exc: + logging.error(f"Error during shutdown: {exc}") + print(colored(f"Error during shutdown: {exc}", "red")) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/requirements.txt b/src/requirements.txt index 29ad75f..22a2dfd 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -21,3 +21,4 @@ mutmut==2.4.4 pyotp>=2.8.0 freezegun +pyperclip diff --git a/src/tests/test_cli_subcommands.py b/src/tests/test_cli_subcommands.py new file mode 100644 index 0000000..e6d6ae7 --- /dev/null +++ b/src/tests/test_cli_subcommands.py @@ -0,0 +1,66 @@ +import sys +from types import SimpleNamespace +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main +from password_manager.entry_types import EntryType + + +def make_pm(search_results, entry=None, totp_code="123456"): + entry_mgr = SimpleNamespace( + search_entries=lambda q: search_results, + retrieve_entry=lambda idx: entry, + get_totp_code=lambda idx, seed: totp_code, + ) + pg = SimpleNamespace(generate_password=lambda l, i: "pw") + pm = SimpleNamespace( + entry_manager=entry_mgr, + password_generator=pg, + nostr_client=SimpleNamespace(close_client_pool=lambda: None), + parent_seed="seed", + inactivity_timeout=1, + ) + return pm + + +def test_search_command(monkeypatch, capsys): + pm = make_pm([(0, "Example", "user", "", False)]) + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + rc = main.main(["search", "ex"]) + assert rc == 0 + out = capsys.readouterr().out + assert "Example" in out + + +def test_get_command(monkeypatch, capsys): + entry = {"type": EntryType.PASSWORD.value, "length": 8} + pm = make_pm([(0, "Example", "user", "", False)], entry=entry) + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + rc = main.main(["get", "ex"]) + assert rc == 0 + out = capsys.readouterr().out + assert "pw" in out + + +def test_totp_command(monkeypatch, capsys): + entry = {"type": EntryType.TOTP.value, "period": 30, "index": 0} + pm = make_pm([(0, "Example", None, None, False)], entry=entry) + called = {} + monkeypatch.setattr(main, "PasswordManager", lambda: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + monkeypatch.setattr(main.pyperclip, "copy", lambda v: called.setdefault("val", v)) + rc = main.main(["totp", "ex"]) + assert rc == 0 + out = capsys.readouterr().out + assert "123456" in out + assert called.get("val") == "123456"