20 Commits

Author SHA1 Message Date
thePR0M3TH3AN
f0e7df54d4 ci: add uv lockfile verification 2025-08-19 09:14:41 -04:00
thePR0M3TH3AN
1b6b0ab5c5 Merge pull request #827 from PR0M3TH3AN/codex/add-backup-restoration-feature
feat: add startup backup restore option
2025-08-19 09:04:29 -04:00
thePR0M3TH3AN
87999b1888 test: cover backup restore startup 2025-08-19 08:53:26 -04:00
thePR0M3TH3AN
6928b4ddbf Merge pull request #826 from PR0M3TH3AN/codex/add-tests-for-seed-word-flow
test: add word-by-word seed flow tests
2025-08-18 22:03:41 -04:00
thePR0M3TH3AN
73183d53a5 test: cover invalid word and fingerprint flows 2025-08-18 21:55:39 -04:00
thePR0M3TH3AN
c9ad16f150 Merge pull request #825 from PR0M3TH3AN/codex/extend-test-coverage-for-key/value-and-managed-accounts
test: add key-value and managed account entry tests
2025-08-18 21:18:20 -04:00
thePR0M3TH3AN
bd86bdbb3a test: add key-value and managed account entry tests 2025-08-18 21:12:32 -04:00
thePR0M3TH3AN
8d5374ef5b Merge pull request #824 from PR0M3TH3AN/codex/add-all_entry_types-constant-and-update-filters
Support listing all entry types
2025-08-18 19:22:05 -04:00
thePR0M3TH3AN
468608a369 Support listing all entry types 2025-08-18 19:12:55 -04:00
thePR0M3TH3AN
56e652089a Merge pull request #823 from PR0M3TH3AN/codex/update-documentation-for-installation-requirements
docs: note installer dependency checks
2025-08-18 18:19:33 -04:00
thePR0M3TH3AN
c353c04472 docs: note installer dependency checks 2025-08-18 18:18:07 -04:00
thePR0M3TH3AN
2559920a14 Merge pull request #822 from PR0M3TH3AN/codex/update-readme-and-examples-for-seedpass-commands
docs: update vault import/export commands
2025-08-18 18:11:18 -04:00
thePR0M3TH3AN
57935bdfc1 docs: update vault import/export commands 2025-08-18 18:09:43 -04:00
thePR0M3TH3AN
55fdee522c Merge pull request #821 from PR0M3TH3AN/codex/update-install.sh-argument-parsing
Default GUI install with opt-out flag
2025-08-18 18:00:14 -04:00
thePR0M3TH3AN
af4eb72385 Default to GUI install with opt-out flag 2025-08-18 17:50:18 -04:00
thePR0M3TH3AN
90c304ff6e Merge pull request #820 from PR0M3TH3AN/codex/add-post-install-python-check
Run CLI import check after pip install
2025-08-18 17:32:25 -04:00
thePR0M3TH3AN
7b1ef2abe2 Relax BIP85 cache benchmark 2025-08-18 17:22:34 -04:00
thePR0M3TH3AN
5194adf145 Check CLI import after installation 2025-08-18 17:02:23 -04:00
thePR0M3TH3AN
8f74ac27f4 Merge pull request #819 from PR0M3TH3AN/codex/add-dependency-installation-for-multiple-os
feat: expand install dependencies
2025-08-18 16:02:53 -04:00
thePR0M3TH3AN
1232630dba feat: expand install dependencies 2025-08-18 15:56:29 -04:00
24 changed files with 471 additions and 81 deletions

View File

@@ -23,7 +23,22 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_CONFIG: .gitleaks.toml
lock-check:
name: Lock Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Compile lockfile
run: uv pip compile --python-version 3.11 --emit-index-url src/requirements.txt -o requirements.lock
- name: Verify lockfile
run: git diff --exit-code requirements.lock
build:
needs: lock-check
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
@@ -69,20 +84,19 @@ jobs:
if: runner.os == 'Windows'
shell: bash
run: echo "${{ steps.msys.outputs.msys2-location }}/mingw64/bin" >> $GITHUB_PATH
- name: Cache pip
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.lock') }}
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('requirements.lock') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Verify lockfile and install dependencies
${{ runner.os }}-uv-
- name: Install uv
run: |
python -m pip install --upgrade pip
pip install pip-tools
pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt
git diff --exit-code requirements.lock
pip install --require-hashes -r requirements.lock
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Sync dependencies
run: uv pip sync --frozen requirements.lock
- name: Run dependency scan
run: scripts/dependency_scan.sh --ignore-vuln GHSA-wj6h-64fc-37mp
- name: Determine stress args

View File

@@ -146,6 +146,10 @@ The Windows installer will attempt to install Git automatically if it is not alr
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
#### Installer Dependency Checks
The installer verifies that core build tooling—C/C++ build tools, Rust, CMake, and the imaging/GTK libraries—are available before completing. Pass `--no-gui` to skip installing GUI packages. On Linux, ensure `xclip` or `wl-clipboard` is installed for clipboard support.
#### Windows Nostr Sync Troubleshooting
When backing up or restoring from Nostr on Windows, a few issues are common:
@@ -274,10 +278,10 @@ You can then launch SeedPass and create a backup:
seedpass
# Export your index
seedpass export --file "~/seedpass_backup.json"
seedpass vault export --file "~/seedpass_backup.json"
# Later you can restore it
seedpass import --file "~/seedpass_backup.json"
seedpass vault import --file "~/seedpass_backup.json"
# Quickly find or retrieve entries
seedpass search "github"
@@ -431,6 +435,16 @@ For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). Th
```
*(or `python src/main.py` when running directly from the repository)*
To restore a previously backed up index at launch, provide the backup path
and fingerprint:
```bash
seedpass --restore-backup /path/to/backup.json.enc --fingerprint <fp>
```
Without the flag, the startup prompt offers a **Restore from backup** option
before the vault is initialized.
2. **Follow the Prompts:**
- **Seed Profile Selection:** If you have existing seed profiles, you'll be prompted to select one or add a new one.
@@ -616,6 +630,10 @@ initial setup. You must provide both your 12word master seed and the master
password that encrypted the vault; without the correct password the retrieved
data cannot be decrypted.
Alternatively, a local backup file can be loaded at startup. Launch the
application with `--restore-backup <file> --fingerprint <fp>` or choose the
**Restore from backup** option presented before the vault initializes.
1. Start SeedPass and choose option **4** when prompted to set up a seed.
2. Paste your BIP85 seed phrase when asked.
3. Enter the master password associated with that seed.

View File

@@ -120,6 +120,11 @@ isn't on your PATH. If these tools are unavailable you'll see a link to download
the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
#### Installer Dependency Checks
The installer verifies that core build tooling—C/C++ build tools, Rust, CMake, and the imaging/GTK libraries—are available before completing. Pass `--no-gui` to skip installing GUI packages. On Linux, ensure `xclip` or `wl-clipboard` is installed for clipboard support.
### Uninstall
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:

View File

@@ -17,7 +17,7 @@ VENV_DIR="$INSTALL_DIR/venv"
LAUNCHER_DIR="$HOME/.local/bin"
LAUNCHER_PATH="$LAUNCHER_DIR/seedpass"
BRANCH="main" # Default branch
INSTALL_GUI=false
INSTALL_GUI=true
# --- Helper Functions ---
print_info() { echo -e "\033[1;34m[INFO]\033[0m" "$1"; }
@@ -32,29 +32,36 @@ install_dependencies() {
sudo apt-get update && sudo apt-get install -y \\
build-essential pkg-config libcairo2 libcairo2-dev \\
libgirepository1.0-dev gobject-introspection \\
gir1.2-gtk-3.0 python3-dev libffi-dev libssl-dev
gir1.2-gtk-3.0 libgtk-3-dev python3-dev libffi-dev libssl-dev \\
cmake rustc cargo zlib1g-dev libjpeg-dev libpng-dev \\
libfreetype6-dev xclip wl-clipboard
elif command -v yum &>/dev/null; then
sudo yum install -y @'Development Tools' cairo cairo-devel \\
gobject-introspection-devel gtk3-devel python3-devel \\
libffi-devel openssl-devel
libffi-devel openssl-devel cmake rust cargo zlib-devel \\
libjpeg-turbo-devel libpng-devel freetype-devel xclip \\
wl-clipboard
elif command -v dnf &>/dev/null; then
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \\
cairo cairo-devel gobject-introspection-devel gtk3-devel \\
python3-devel libffi-devel openssl-devel
python3-devel libffi-devel openssl-devel cmake rust cargo \\
zlib-devel libjpeg-turbo-devel libpng-devel freetype-devel \\
xclip wl-clipboard
elif command -v pacman &>/dev/null; then
sudo pacman -Syu --noconfirm base-devel pkgconf cairo \\
gobject-introspection gtk3 python
sudo pacman -Syu --noconfirm base-devel pkgconf cmake rustup \\
gtk3 gobject-introspection cairo libjpeg-turbo zlib \\
libpng freetype xclip wl-clipboard && rustup default stable
elif command -v brew &>/dev/null; then
brew install pkg-config cairo gobject-introspection gtk+3
brew install pkg-config cairo gobject-introspection gtk+3 cmake rustup-init && \\
rustup-init -y
else
print_warning "Unsupported package manager. Please install Gtk/GObject dependencies manually."
fi
print_warning "Install 'xclip' manually to enable clipboard features in secret mode."
}
usage() {
echo "Usage: $0 [-b | --branch <branch_name>] [--with-gui] [-h | --help]"
echo "Usage: $0 [-b | --branch <branch_name>] [--no-gui] [-h | --help]"
echo " -b, --branch Specify the git branch to install (default: main)"
echo " --with-gui Include graphical interface dependencies"
echo " --no-gui Skip graphical interface dependencies (default: include GUI)"
echo " -h, --help Display this help message"
exit 0
}
@@ -75,8 +82,8 @@ main() {
-h|--help)
usage
;;
--with-gui)
INSTALL_GUI=true
--no-gui)
INSTALL_GUI=false
shift
;;
*)
@@ -181,6 +188,11 @@ main() {
else
pip install -e .
fi
if ! "$VENV_DIR/bin/python" -c "import seedpass.cli; print('ok')"; then
print_error "SeedPass CLI import check failed."
fi
deactivate
# 7. Create launcher script

View File

@@ -19,7 +19,7 @@ from termcolor import colored
from utils.color_scheme import color_text
import importlib
from seedpass.core.manager import PasswordManager
from seedpass.core.manager import PasswordManager, restore_backup_index
from nostr.client import NostrClient
from seedpass.core.entry_types import EntryType
from seedpass.core.config_manager import ConfigManager
@@ -1285,6 +1285,10 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
load_global_config()
parser = argparse.ArgumentParser()
parser.add_argument("--fingerprint")
parser.add_argument(
"--restore-backup",
help="Restore index from backup file before starting",
)
parser.add_argument(
"--no-clipboard",
action="store_true",
@@ -1315,6 +1319,41 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
args = parser.parse_args(argv)
if args.restore_backup:
fp_target = args.fingerprint or fingerprint
if fp_target is None:
print(
colored(
"Error: --fingerprint is required when using --restore-backup.",
"red",
)
)
return 1
try:
restore_backup_index(Path(args.restore_backup), fp_target)
logger.info("Restored backup from %s", args.restore_backup)
except Exception as e:
logger.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
return 1
elif args.command is None:
print("Startup Options:")
print("1. Continue")
print("2. Restore from backup")
choice = input("Select an option: ").strip()
if choice == "2":
path = input("Enter backup file path: ").strip()
fp_target = args.fingerprint or fingerprint
if fp_target is None:
fp_target = input("Enter fingerprint for restore: ").strip()
try:
restore_backup_index(Path(path), fp_target)
logger.info("Restored backup from %s", path)
except Exception as e:
logger.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
return 1
if args.max_prompt_attempts is not None:
os.environ["SEEDPASS_MAX_PROMPT_ATTEMPTS"] = str(args.max_prompt_attempts)

View File

@@ -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:

View File

@@ -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")

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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]

View File

@@ -158,6 +158,31 @@ def calculate_profile_id(seed: str) -> str:
return fp or ""
def restore_backup_index(path: Path, fingerprint: str) -> None:
"""Restore the encrypted index for ``fingerprint`` from ``path``.
This helper is intended for use before full :class:`PasswordManager`
initialization. It simply copies the provided backup file into the
profile's directory, replacing the existing index if present. A copy of
the previous index is kept with a ``.bak`` suffix to allow manual
recovery if needed.
"""
fingerprint_dir = APP_DIR / fingerprint
fingerprint_dir.mkdir(parents=True, exist_ok=True)
dest = fingerprint_dir / "seedpass_entries_db.json.enc"
src = Path(path)
# Ensure the source file can be read
src.read_bytes()
if dest.exists():
shutil.copy2(dest, dest.with_suffix(".bak"))
shutil.copy2(src, dest)
os.chmod(dest, 0o600)
@dataclass
class Notification:
"""Simple message container for UI notifications."""

View File

@@ -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:

View File

@@ -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)

View File

@@ -0,0 +1,56 @@
import main
from pathlib import Path
def test_cli_flag_restores_before_init(monkeypatch, tmp_path):
calls = []
backup = tmp_path / "bak.json"
backup.write_text("{}")
def fake_restore(path, fingerprint):
calls.append(("restore", Path(path), fingerprint))
class DummyPM:
def __init__(self, fingerprint=None):
calls.append(("init", fingerprint))
self.secret_mode_enabled = True
self.inactivity_timeout = 0
monkeypatch.setattr(main, "restore_backup_index", fake_restore)
monkeypatch.setattr(main, "PasswordManager", DummyPM)
monkeypatch.setattr(main, "display_menu", lambda pm, **k: None)
rc = main.main(["--fingerprint", "fp", "--restore-backup", str(backup)])
assert rc == 0
assert calls[0][0] == "restore"
assert calls[1][0] == "init"
assert calls[0][1] == backup
assert calls[0][2] == "fp"
def test_menu_option_restores_before_init(monkeypatch, tmp_path):
calls = []
backup = tmp_path / "bak.json"
backup.write_text("{}")
def fake_restore(path, fingerprint):
calls.append(("restore", Path(path), fingerprint))
class DummyPM:
def __init__(self, fingerprint=None):
calls.append(("init", fingerprint))
self.secret_mode_enabled = True
self.inactivity_timeout = 0
monkeypatch.setattr(main, "restore_backup_index", fake_restore)
monkeypatch.setattr(main, "PasswordManager", DummyPM)
monkeypatch.setattr(main, "display_menu", lambda pm, **k: None)
inputs = iter(["2", str(backup)])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(inputs))
rc = main.main(["--fingerprint", "fp"])
assert rc == 0
assert calls[0][0] == "restore"
assert calls[1][0] == "init"
assert calls[0][1] == backup
assert calls[0][2] == "fp"

View File

@@ -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: [

View File

@@ -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

View File

@@ -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):

View File

@@ -37,10 +37,30 @@ def test_add_and_modify_key_value():
"tags": [],
}
# Appears in listing
assert em.list_entries() == [(idx, "API entry", None, None, False)]
# Modify key and value
em.modify_entry(idx, key="api_key2", value="def456")
updated = em.retrieve_entry(idx)
assert updated["key"] == "api_key2"
assert updated["value"] == "def456"
# Archive and ensure it disappears from the default listing
em.archive_entry(idx)
archived = em.retrieve_entry(idx)
assert archived["archived"] is True
assert em.list_entries() == []
assert em.list_entries(include_archived=True) == [
(idx, "API entry", None, None, True)
]
# Restore and ensure it reappears
em.restore_entry(idx)
restored = em.retrieve_entry(idx)
assert restored["archived"] is False
assert em.list_entries() == [(idx, "API entry", None, None, False)]
# Values are not searchable
results = em.search_entries("def456")
assert results == []

View File

@@ -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

View File

@@ -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)]

View File

@@ -41,6 +41,9 @@ def test_add_and_get_managed_account_seed():
assert fp
assert (tmp_path / "accounts" / fp).exists()
# Appears in listing
assert mgr.list_entries() == [(idx, "acct", None, None, False)]
phrase_a = mgr.get_managed_account_seed(idx, TEST_SEED)
phrase_b = mgr.get_managed_account_seed(idx, TEST_SEED)
assert phrase_a == phrase_b
@@ -51,6 +54,23 @@ def test_add_and_get_managed_account_seed():
assert phrase_a == expected
assert generate_fingerprint(phrase_a) == fp
# Archive and ensure it disappears from default listing
mgr.archive_entry(idx)
archived = mgr.retrieve_entry(idx)
assert archived["archived"] is True
assert mgr.list_entries() == []
assert mgr.list_entries(include_archived=True) == [
(idx, "acct", None, None, True)
]
# Restore and ensure deterministic derivation is unchanged
mgr.restore_entry(idx)
restored = mgr.retrieve_entry(idx)
assert restored["archived"] is False
assert mgr.list_entries() == [(idx, "acct", None, None, False)]
phrase_c = mgr.get_managed_account_seed(idx, TEST_SEED)
assert phrase_c == expected
def test_load_and_exit_managed_account(monkeypatch):
with TemporaryDirectory() as tmpdir:

View File

@@ -0,0 +1,93 @@
import builtins
from types import SimpleNamespace
import pytest
import seedpass.core.manager as manager_module
from helpers import TEST_SEED
from utils import seed_prompt
def test_prompt_seed_words_confirmation_loop(monkeypatch):
phrase = TEST_SEED
words = phrase.split()
inputs = iter(words + [words[2]])
confirmations = iter(["y", "y", "n", "y"] + ["y"] * (len(words) - 3))
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(inputs))
monkeypatch.setattr(seed_prompt, "_apply_backoff", lambda *_a, **_k: None)
monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(confirmations))
result = seed_prompt.prompt_seed_words(len(words))
assert result == phrase
def test_prompt_seed_words_invalid_word(monkeypatch):
phrase = TEST_SEED
words = phrase.split()
inputs = iter(["invalid"] + words)
confirmations = iter(["y"] * len(words))
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(inputs))
monkeypatch.setattr(seed_prompt, "_apply_backoff", lambda *_a, **_k: None)
monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(confirmations))
result = seed_prompt.prompt_seed_words(len(words))
assert result == phrase
def test_add_new_fingerprint_words_flow_success(monkeypatch):
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.fingerprint_manager = SimpleNamespace(current_fingerprint=None)
pm.initialize_managers = lambda: None
phrase = TEST_SEED
words = phrase.split()
word_iter = iter(words)
inputs = iter(["2"] + ["y"] * len(words))
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter))
monkeypatch.setattr(seed_prompt, "_apply_backoff", lambda *_a, **_k: None)
monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(inputs))
captured = {}
def finalize(self, seed, password=None):
captured["seed"] = seed
self.parent_seed = seed
return "fp"
monkeypatch.setattr(
manager_module.PasswordManager, "_finalize_existing_seed", finalize
)
result = pm.add_new_fingerprint()
assert result == "fp"
assert pm.fingerprint_manager.current_fingerprint == "fp"
assert captured["seed"] == phrase
assert pm.parent_seed == phrase
def test_add_new_fingerprint_words_flow_invalid_phrase(monkeypatch):
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.fingerprint_manager = SimpleNamespace(current_fingerprint=None)
pm.initialize_managers = lambda: None
words = ["abandon"] * 12
word_iter = iter(words)
inputs = iter(["2"] + ["y"] * len(words))
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter))
monkeypatch.setattr(seed_prompt, "_apply_backoff", lambda *_a, **_k: None)
monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
pm.add_new_fingerprint()
assert pm.fingerprint_manager.current_fingerprint is None
assert not hasattr(pm, "parent_seed")

View File

@@ -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(

View File

@@ -46,7 +46,7 @@ def test_bip85_cache_benchmark():
for _ in range(3):
pm.get_bip85_entropy(32, 1)
cached_time = time.perf_counter() - start
assert cached_time < uncached_time
# Ensure caching avoids redundant derive calls without relying on
# potentially flaky timing comparisons across platforms.
assert slow_uncached.calls == 3
assert slow_cached.calls == 1