Advance TUI v2 parity with reveal/QR and landing theme

This commit is contained in:
thePR0M3TH3AN committed 2026-03-01 18:20:57 -05:00
1 parent f02b360062
commit 92f17355ad
7 files changed
+800 -20

No files matched your search

+35
View File
@@ -249,3 +249,38 @@
- `seedpass tui2 --no-fallback-legacy`: preflight failures now return exit code 1 with a clear error.
- Updated installer `scripts/install.sh` to run `seedpass tui2 --check` after install and warn when Textual runtime is unavailable, plus print a legacy fallback hint for users seeing blank/gray startup.
- Added regression coverage in `src/tests/test_typer_cli.py` for preflight behavior and adapted existing TUI launch tests to mock preflight service creation.
## 2026-03-01 TUI v2 parity slice: reveal + QR integration
- Added Textual TUI v2 sensitive-data actions:
- Keybindings: `v` reveal selected secret, `g` show QR.
- Palette commands: `reveal`, `qr`.
- Implemented reveal support for entry kinds: `password`, `seed`, `managed_account`, `totp`.
- Implemented QR rendering in-app (ASCII QR panel) for:
- TOTP (`otpauth://` payload)
- Seed / managed account seed (SeedQR digit payload)
- Added a dedicated `#secret-detail` panel in the right pane and reset-on-selection behavior to avoid stale secret display.
- Extended core `EntryService` API with deterministic seed retrieval methods used by TUI v2:
- `get_seed_phrase(entry_id)`
- `get_managed_account_seed(entry_id)`
- Added/updated tests for helper QR rendering, TUI reveal/QR workflows, and service wrappers; targeted suite passed.
## 2026-03-01 TUI v2 parity slice: SSH/PGP/Nostr + secret mode behavior
- Extended TUI v2 sensitive reveal support to additional kinds: `ssh`, `pgp`, and `nostr`.
- Added legacy-aligned confirmation semantics in TUI v2:
- `reveal confirm` required for high-risk reveals (`seed`, `managed_account`, `ssh`, `pgp`).
- `qr private confirm` required for Nostr private-key QR rendering.
- Added `qr` mode parsing (`qr [public|private] [confirm]`) and retained default safe behavior (`public` for Nostr).
- Integrated secret-mode behavior into TUI reveal flow:
- when secret mode is enabled, reveal copies sensitive value to clipboard and avoids on-screen plaintext output for that reveal.
- Expanded `EntryService` API for TUI parity and safe abstraction from manager internals:
- `get_ssh_key_pair`, `get_pgp_key`, `get_nostr_key_pair`
- `get_secret_mode_enabled`, `get_clipboard_clear_delay`, `copy_to_clipboard`
- Added/updated tests covering confirmation-gated flows, Nostr private QR confirmation, and secret-mode clipboard behavior.
## 2026-03-01 TUI v2 visual alignment with landing page
- Updated Textual TUI v2 default styling to mirror landing-page palette tokens:
- background: `#080a0c` / panel: `#0d1114`
- primary text: `#97b8a6` / bright text: `#daf2e5`
- accent borders/focus: `#58f29d`, `#2abf75`, `#274533`, `#1a3024`
- Styled Header/Footer/Input/ListView/ListItem focus/selection states to reduce default Textual blue and align with SeedPass mint-green terminal aesthetic.
- Added website-style icon semantics into TUI copy and sensitive panel titles (key/mobile/network/lock/bolt/document/users/seed mappings) to improve visual continuity between landing and app.
+71
View File
@@ -18,6 +18,7 @@ from pydantic import BaseModel
from .manager import PasswordManager
from .pubsub import bus
from .entry_types import EntryType
from utils import copy_to_clipboard
class VaultExportRequest(BaseModel):
@@ -317,6 +318,76 @@ class EntryService:
entry_id, self._manager.parent_seed
)
def get_seed_phrase(self, entry_id: int) -> str:
"""Return the derived seed phrase for a seed entry."""
with self._lock:
return self._manager.entry_manager.get_seed_phrase(
entry_id, self._manager.parent_seed
)
def get_managed_account_seed(self, entry_id: int) -> str:
"""Return the derived seed phrase for a managed account entry."""
with self._lock:
return self._manager.entry_manager.get_managed_account_seed(
entry_id, self._manager.parent_seed
)
def get_ssh_key_pair(self, entry_id: int) -> tuple[str, str]:
"""Return ``(private_key_pem, public_key_pem)`` for an SSH entry."""
with self._lock:
return self._manager.entry_manager.get_ssh_key_pair(
entry_id, self._manager.parent_seed
)
def get_pgp_key(self, entry_id: int) -> tuple[str, str]:
"""Return ``(private_key_armored, fingerprint)`` for a PGP entry."""
with self._lock:
return self._manager.entry_manager.get_pgp_key(
entry_id, self._manager.parent_seed
)
def get_nostr_key_pair(self, entry_id: int) -> tuple[str, str]:
"""Return ``(npub, nsec)`` for a Nostr entry."""
with self._lock:
return self._manager.entry_manager.get_nostr_key_pair(
entry_id, self._manager.parent_seed
)
def get_secret_mode_enabled(self) -> bool:
"""Return whether secret mode is currently enabled."""
with self._lock:
cfg = getattr(self._manager, "config_manager", None)
if cfg is not None:
try:
return bool(cfg.get_secret_mode_enabled())
except Exception:
pass
return bool(getattr(self._manager, "secret_mode_enabled", False))
def get_clipboard_clear_delay(self) -> int:
"""Return clipboard clear delay in seconds."""
with self._lock:
cfg = getattr(self._manager, "config_manager", None)
if cfg is not None:
try:
return int(cfg.get_clipboard_clear_delay())
except Exception:
pass
return int(getattr(self._manager, "clipboard_clear_delay", 30))
def copy_to_clipboard(self, value: str) -> bool:
"""Copy ``value`` to clipboard using configured clear delay."""
with self._lock:
cfg = getattr(self._manager, "config_manager", None)
if cfg is not None:
try:
delay = int(cfg.get_clipboard_clear_delay())
except Exception:
delay = int(getattr(self._manager, "clipboard_clear_delay", 30))
else:
delay = int(getattr(self._manager, "clipboard_clear_delay", 30))
return copy_to_clipboard(value, delay)
def add_entry(
self,
label: str,
+411 -20
View File
@@ -69,6 +69,20 @@ def truncate_entry_for_display(
return payload
def render_qr_ascii(data: str) -> str:
"""Render ``data`` as an ASCII QR code."""
import qrcode
qr = qrcode.QRCode(border=1)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
lines: list[str] = []
for row in matrix:
lines.append("".join("##" if cell else " " for cell in row))
return "\n".join(lines)
def launch_tui2(
*,
fingerprint: str | None = None,
@@ -103,35 +117,82 @@ def launch_tui2(
DETAIL_CONTENT_PREVIEW_LIMIT = 4000
CSS = """
Screen {
background: #080a0c;
color: #97b8a6;
}
Header {
background: #0b0f13;
color: #daf2e5;
text-style: bold;
border-bottom: solid #274533;
}
Footer {
background: #0b0f13;
color: #97b8a6;
border-top: solid #274533;
}
Input {
background: #0d1114;
color: #daf2e5;
border: solid #1a3024;
}
Input:focus {
border: heavy #58f29d;
}
ListView {
background: #0d1114;
border: solid #1a3024;
}
ListItem {
color: #97b8a6;
}
ListItem.-highlight {
background: #122019;
color: #daf2e5;
text-style: bold;
}
Static {
color: #97b8a6;
}
#command-palette {
height: 2;
margin: 0 1 1 1;
border: solid $secondary;
background: $panel;
border: solid #2abf75;
background: #0d1114;
color: #daf2e5;
}
#body { height: 1fr; }
#status {
height: 1;
padding: 0 1;
border: heavy $primary;
background: $panel;
border: heavy #58f29d;
background: #0d1114;
color: #daf2e5;
}
#left { width: 34; border: solid $primary; padding: 1; background: $panel; }
#center { width: 1fr; border: solid $primary; padding: 1; background: $panel; }
#right { width: 1fr; border: solid $primary; padding: 1; background: $panel; }
#left { width: 34; border: solid #1a3024; padding: 1; background: #0d1114; }
#center { width: 1fr; border: solid #1a3024; padding: 1; background: #0d1114; }
#right { width: 1fr; border: solid #1a3024; padding: 1; background: #0d1114; }
#search { margin-bottom: 1; }
#quick-jump { margin-bottom: 1; }
#entry-list { height: 1fr; }
#entry-detail {
height: 1fr;
overflow: auto;
border: solid $boost;
border: solid #274533;
padding: 1;
}
#link-detail {
height: 12;
overflow: auto;
border: solid $accent;
border: solid #2abf75;
padding: 1;
margin-top: 1;
}
#secret-detail {
height: 12;
overflow: auto;
border: solid #58f29d;
padding: 1;
margin-top: 1;
}
@@ -147,18 +208,19 @@ def launch_tui2(
layer: overlay;
dock: top;
margin: 1 2;
border: solid $warning;
border: solid #58f29d;
padding: 1;
background: $surface;
background: #0d1114;
color: #daf2e5;
}
#activity {
height: 12;
border: solid $secondary;
border: solid #274533;
padding: 1;
margin-top: 1;
overflow: auto;
}
.pane-focus { border: heavy $success; }
.pane-focus { border: heavy #58f29d; }
.hidden { display: none; }
"""
BINDINGS = [
@@ -178,6 +240,8 @@ def launch_tui2(
("[", "prev_link", "Prev Link"),
("]", "next_link", "Next Link"),
("o", "open_link_target", "Open Link"),
("v", "reveal_selected", "Reveal"),
("g", "show_qr", "QR"),
("a", "toggle_archive", "Archive/Restore"),
("e", "edit_document", "Edit Document"),
("ctrl+s", "save_document", "Save"),
@@ -199,6 +263,7 @@ def launch_tui2(
"filter <kind> | archive | restore | "
"link-add <target> [relation] [note] | "
"link-rm <target> [relation] | "
"reveal [confirm] | qr [public|private] [confirm] | "
"link-filter <relation|all> | link-next | link-prev | link-open | "
"page-next | page-prev | page <n> | retry"
),
@@ -220,6 +285,7 @@ def launch_tui2(
with Vertical(id="right-view"):
yield Static("", id="entry-detail")
yield Static("", id="link-detail")
yield Static("", id="secret-detail")
with Vertical(id="right-editor", classes="hidden"):
yield Input(placeholder="Document title", id="doc-edit-label")
yield Input(
@@ -279,6 +345,9 @@ def launch_tui2(
self._update_help_overlay()
self._update_activity_panel()
self._apply_focus_style()
self._set_secret_panel(
"Sensitive data hidden. Use 'v' to reveal 🔑 or 'g' for QR ▦."
)
self._load_entries()
def _set_status(self, message: str) -> None:
@@ -313,6 +382,9 @@ def launch_tui2(
text = "\n".join(lines)
self.query_one("#activity", Static).update(text)
def _set_secret_panel(self, text: str) -> None:
self.query_one("#secret-detail", Static).update(text)
def _update_help_overlay(self) -> None:
box = self.query_one("#help-overlay", Static)
if not self.help_open:
@@ -325,12 +397,13 @@ def launch_tui2(
"",
"Core : / search j jump-id p/n page r refresh",
"Modes : Ctrl+P palette e edit-doc Ctrl+S save Esc cancel/close",
"Graph : l relation filter brackets link select o open link target",
"Graph ⚯ : l relation filter brackets link select o open link target",
"Secrets 🔑: v reveal selected secret g QR for selected entry",
"Resilience: x retry last error",
"Pane Focus: 1 left 2 center 3 right",
"",
"Palette examples",
"help | filter document | open 12 | link-add 7 references note",
"help | filter document | open 12 | reveal confirm | qr private confirm",
]
)
box.update(text)
@@ -410,7 +483,7 @@ def launch_tui2(
)
text = "\n".join(
[
"SeedPass TUI v2",
"SeedPass ◈ TUI v2",
fp_line,
"",
self._selected_summary(),
@@ -421,7 +494,7 @@ def launch_tui2(
f"Page: {self._result_page + 1}/{self._total_pages()}"
),
"",
"Nav",
"Nav ⌘",
"- / search",
"- j jump to id",
"- f cycle kind filter",
@@ -431,7 +504,7 @@ def launch_tui2(
"- r refresh",
"- x retry last error",
"",
"Actions",
"Actions ⚡",
"- a archive/restore",
"- e edit document",
"- Ctrl+S save doc",
@@ -439,6 +512,8 @@ def launch_tui2(
"- l cycle link relation",
"- [ / ] select link",
"- o open link target",
"- v reveal selected secret",
"- g show selected QR",
"- Esc cancel/close",
]
)
@@ -468,6 +543,9 @@ def launch_tui2(
self.query_one("#link-detail", Static).update(
"Links: select an entry first."
)
self._set_secret_panel(
"Sensitive data hidden. Select an entry, then use 'v' (reveal) or 'g' (QR)."
)
self._current_links = []
self._current_link_cursor = 0
self._set_status("No entries match current filter/search")
@@ -516,6 +594,9 @@ def launch_tui2(
"Entry service unavailable in this runtime."
)
self.query_one("#link-detail", Static).update("Links unavailable.")
self._set_secret_panel(
"Sensitive data unavailable: entry service missing."
)
self._all_results = []
self._result_page = 0
self._current_links = []
@@ -532,6 +613,9 @@ def launch_tui2(
f"Failed to load entries: {exc}"
)
self.query_one("#link-detail", Static).update("Links unavailable.")
self._set_secret_panel(
"Sensitive data unavailable: failed to load entries."
)
self._all_results = []
self._result_page = 0
self._current_links = []
@@ -571,6 +655,9 @@ def launch_tui2(
self._selected_entry = dict(entry)
body = self._entry_detail_text(entry)
self.query_one("#entry-detail", Static).update(body)
self._set_secret_panel(
"Sensitive data hidden. Use 'v' to reveal 🔑 or 'g' for QR ▦."
)
self._update_links_panel()
self._set_status(f"Selected entry {entry_index}")
except Exception as exc:
@@ -578,6 +665,9 @@ def launch_tui2(
f"Failed to load entry {entry_index}: {exc}"
)
self.query_one("#link-detail", Static).update("Links unavailable.")
self._set_secret_panel(
"Sensitive data unavailable: failed to load selected entry."
)
self._current_links = []
self._current_link_cursor = 0
self._record_failure(
@@ -672,6 +762,278 @@ def launch_tui2(
)
return kind == "document"
def _selected_kind(self) -> str:
if not isinstance(self._selected_entry, dict):
return ""
return (
str(
self._selected_entry.get("kind")
or self._selected_entry.get("type")
or ""
)
.strip()
.lower()
)
@staticmethod
def _kind_icon(kind: str) -> str:
return {
"password": "🗝",
"totp": "📱",
"ssh": "🖧",
"pgp": "🔒",
"nostr": "⚡",
"document": "📄",
"managed_account": "👥",
"seed": "🌱",
}.get(kind, "•")
def _is_secret_mode_enabled(self) -> bool:
if self._service is None:
return False
getter = getattr(self._service, "get_secret_mode_enabled", None)
if not callable(getter):
return False
try:
return bool(getter())
except Exception:
return False
def _clipboard_clear_delay(self) -> int:
if self._service is None:
return 30
getter = getattr(self._service, "get_clipboard_clear_delay", None)
if not callable(getter):
return 30
try:
return int(getter())
except Exception:
return 30
def _copy_to_clipboard(self, value: str) -> bool:
if self._service is None:
return False
copier = getattr(self._service, "copy_to_clipboard", None)
if not callable(copier):
return False
try:
return bool(copier(value))
except Exception:
return False
def _resolve_selected_sensitive_payload(
self, *, qr_mode: str = "default"
) -> tuple[str, str, str | None, str | None, str]:
if self._service is None:
raise ValueError("Entry service unavailable")
if self._selected_entry_id is None:
raise ValueError("No entry selected")
entry = self._selected_entry or self._service.retrieve_entry(
self._selected_entry_id
)
if not isinstance(entry, dict) or not entry:
raise ValueError("Selected entry not found")
kind = (
self._selected_kind()
or str(entry.get("kind") or entry.get("type") or "").strip().lower()
)
label = str(entry.get("label", f"entry-{self._selected_entry_id}"))
icon = self._kind_icon(kind)
if kind == "password":
length = int(entry.get("length", 16))
password = self._service.generate_password(
length, self._selected_entry_id
)
return (
f"Sensitive {icon}: Password #{self._selected_entry_id}",
f"Label : {label}\nPassword : {password}",
None,
password,
kind,
)
if kind == "seed":
phrase = self._service.get_seed_phrase(self._selected_entry_id)
from seedpass.core.seedqr import encode_seedqr
seedqr = encode_seedqr(phrase)
return (
f"Sensitive {icon}: Seed #{self._selected_entry_id}",
f"Label : {label}\nSeed Phrase : {phrase}",
seedqr,
phrase,
kind,
)
if kind == "managed_account":
phrase = self._service.get_managed_account_seed(self._selected_entry_id)
from seedpass.core.seedqr import encode_seedqr
seedqr = encode_seedqr(phrase)
return (
f"Sensitive {icon}: Managed Account Seed #{self._selected_entry_id}",
f"Label : {label}\nSeed Phrase : {phrase}",
seedqr,
phrase,
kind,
)
if kind == "totp":
secret = self._service.get_totp_secret(self._selected_entry_id)
code = self._service.get_totp_code(self._selected_entry_id)
period = int(entry.get("period", 30))
digits = int(entry.get("digits", 6))
from seedpass.core.totp import TotpManager
uri = TotpManager.make_otpauth_uri(
label, secret, period=period, digits=digits
)
return (
f"Sensitive {icon}: TOTP #{self._selected_entry_id}",
(
f"Label : {label}\n"
f"Code : {code}\n"
f"Secret : {secret}\n"
f"Period : {period}s\n"
f"Digits : {digits}"
),
uri,
code,
kind,
)
if kind == "ssh":
priv_key, pub_key = self._service.get_ssh_key_pair(
self._selected_entry_id
)
return (
f"Sensitive {icon}: SSH #{self._selected_entry_id}",
f"Label : {label}\nPublic Key : {pub_key}\nPrivate Key : {priv_key}",
None,
priv_key,
kind,
)
if kind == "pgp":
priv_key, fingerprint_text = self._service.get_pgp_key(
self._selected_entry_id
)
return (
f"Sensitive {icon}: PGP #{self._selected_entry_id}",
(
f"Label : {label}\n"
f"Fingerprint : {fingerprint_text}\n"
f"Private Key :\n{priv_key}"
),
None,
priv_key,
kind,
)
if kind == "nostr":
npub, nsec = self._service.get_nostr_key_pair(self._selected_entry_id)
if qr_mode == "private":
qr_payload = nsec
else:
qr_payload = f"nostr:{npub}"
return (
f"Sensitive {icon}: Nostr #{self._selected_entry_id}",
f"Label : {label}\nnpub : {npub}\nnsec : {nsec}",
qr_payload,
nsec,
kind,
)
raise ValueError(
"Reveal unsupported for kind "
f"'{kind or 'unknown'}'. Supported: password, seed, managed_account, "
"totp, ssh, pgp, nostr."
)
def _requires_confirm(
self, *, kind: str, include_qr: bool, qr_mode: str
) -> bool:
if include_qr:
return kind == "nostr" and qr_mode == "private"
return kind in {"seed", "managed_account", "ssh", "pgp"}
def _show_sensitive_panel(
self, *, include_qr: bool, qr_mode: str = "default", confirm: bool = False
) -> None:
try:
title, body, qr_data, secret_value, kind = (
self._resolve_selected_sensitive_payload(qr_mode=qr_mode)
)
except Exception as exc:
self._record_failure(
"Sensitive reveal failed",
exc,
retry=(
(lambda: self.action_show_qr(mode=qr_mode, confirm=confirm))
if include_qr
else (lambda: self.action_reveal_selected(confirm=confirm))
),
hint="Press 'x' to retry.",
)
return
if (
self._requires_confirm(
kind=kind, include_qr=include_qr, qr_mode=qr_mode
)
and not confirm
):
if include_qr:
self._set_status(
"Sensitive QR requires confirmation. Run: qr private confirm"
)
else:
self._set_status(
"Sensitive reveal requires confirmation. Run: reveal confirm"
)
return
if include_qr:
if not qr_data:
self._set_secret_panel(
f"{title}\n\nQR not supported for this entry."
)
self._clear_failure()
self._set_status("QR not available for selected entry")
return
try:
qr_ascii = render_qr_ascii(qr_data)
except Exception as exc:
self._record_failure(
"QR render failed",
exc,
retry=self.action_show_qr,
hint="Press 'x' to retry.",
)
return
self._set_secret_panel(
f"{title} QR\n\n{qr_ascii}\n\nPayload: {qr_data}"
)
self._clear_failure()
self._set_status("Rendered QR for selected entry")
return
self._set_secret_panel(f"{title}\n\n{body}")
self._clear_failure()
if self._is_secret_mode_enabled() and secret_value:
if self._copy_to_clipboard(secret_value):
delay = self._clipboard_clear_delay()
self._set_secret_panel(
f"{title}\n\nSecret mode is enabled.\n"
f"Sensitive value copied to clipboard (auto-clear in {delay}s)."
)
self._set_status("Sensitive value copied to clipboard")
return
self._set_status("Revealed selected sensitive data")
def _set_document_editor_visible(self, visible: bool) -> None:
view = self.query_one("#right-view", Vertical)
editor = self.query_one("#right-editor", Vertical)
@@ -732,8 +1094,9 @@ def launch_tui2(
self._set_status(
"Palette commands: help, open, search, filter, archive, "
"restore, edit-doc, save-doc, cancel-edit, link-add, link-rm, "
"link-filter, link-next, link-prev, link-open, page-next, "
"page-prev, page <n>, retry, jump <id>"
"reveal [confirm], qr [public|private] [confirm], link-filter, "
"link-next, link-prev, link-open, page-next, page-prev, page <n>, "
"retry, jump <id>"
)
return
if cmd == "retry":
@@ -817,6 +1180,28 @@ def launch_tui2(
if cmd == "cancel-edit":
self.action_cancel_document_edit()
return
if cmd == "reveal":
if len(args) > 1 or (len(args) == 1 and args[0].lower() != "confirm"):
self._set_status("Usage: reveal [confirm]")
return
confirm = len(args) == 1 and args[0].lower() == "confirm"
self.action_reveal_selected(confirm=confirm)
return
if cmd == "qr":
mode = "default"
confirm = False
for token in args:
value = token.strip().lower()
if value in {"public", "private"}:
mode = value
continue
if value == "confirm":
confirm = True
continue
self._set_status("Usage: qr [public|private] [confirm]")
return
self.action_show_qr(mode=mode, confirm=confirm)
return
if cmd == "link-add":
if self._service is None or self._selected_entry_id is None:
@@ -1119,6 +1504,12 @@ def launch_tui2(
else:
self._set_status("Palette closed")
def action_reveal_selected(self, confirm: bool = False) -> None:
self._show_sensitive_panel(include_qr=False, confirm=confirm)
def action_show_qr(self, mode: str = "default", confirm: bool = False) -> None:
self._show_sensitive_panel(include_qr=True, qr_mode=mode, confirm=confirm)
def action_toggle_archive(self) -> None:
if self._service is None or self._selected_entry_id is None:
self._set_status("No entry selected")
+56
View File
@@ -286,6 +286,62 @@ class TestEntryService:
mock_manager.entry_manager.get_totp_code.assert_called_once_with(1, b"key")
assert code == "123456"
def test_get_seed_phrase(self, service, mock_manager):
mock_manager.entry_manager.get_seed_phrase.return_value = "seed words"
phrase = service.get_seed_phrase(7)
mock_manager.entry_manager.get_seed_phrase.assert_called_once_with(
7, mock_manager.parent_seed
)
assert phrase == "seed words"
def test_get_managed_account_seed(self, service, mock_manager):
mock_manager.entry_manager.get_managed_account_seed.return_value = (
"managed words"
)
phrase = service.get_managed_account_seed(8)
mock_manager.entry_manager.get_managed_account_seed.assert_called_once_with(
8, mock_manager.parent_seed
)
assert phrase == "managed words"
def test_get_ssh_key_pair(self, service, mock_manager):
mock_manager.entry_manager.get_ssh_key_pair.return_value = ("priv", "pub")
result = service.get_ssh_key_pair(3)
mock_manager.entry_manager.get_ssh_key_pair.assert_called_once_with(
3, mock_manager.parent_seed
)
assert result == ("priv", "pub")
def test_get_pgp_key(self, service, mock_manager):
mock_manager.entry_manager.get_pgp_key.return_value = ("priv", "fp")
result = service.get_pgp_key(4)
mock_manager.entry_manager.get_pgp_key.assert_called_once_with(
4, mock_manager.parent_seed
)
assert result == ("priv", "fp")
def test_get_nostr_key_pair(self, service, mock_manager):
mock_manager.entry_manager.get_nostr_key_pair.return_value = ("npub", "nsec")
result = service.get_nostr_key_pair(5)
mock_manager.entry_manager.get_nostr_key_pair.assert_called_once_with(
5, mock_manager.parent_seed
)
assert result == ("npub", "nsec")
def test_get_secret_mode_enabled(self, service, mock_manager):
mock_manager.config_manager.get_secret_mode_enabled.return_value = True
assert service.get_secret_mode_enabled() is True
def test_get_clipboard_clear_delay(self, service, mock_manager):
mock_manager.config_manager.get_clipboard_clear_delay.return_value = 40
assert service.get_clipboard_clear_delay() == 40
def test_copy_to_clipboard(self, service, mock_manager):
mock_manager.config_manager.get_clipboard_clear_delay.return_value = 35
with patch("seedpass.core.api.copy_to_clipboard", return_value=True) as patched:
assert service.copy_to_clipboard("secret-value") is True
patched.assert_called_once_with("secret-value", 35)
def test_add_entry(self, service, mock_manager):
mock_manager.entry_manager.add_entry.return_value = 1
+66
View File
@@ -35,6 +35,9 @@ class MatrixEntryService:
self.fail_modify = fail_modify
self.fail_add_link = fail_add_link
self.fail_remove_link = fail_remove_link
self.secret_mode_enabled = False
self.clipboard_delay = 30
self.clipboard_values: list[str] = []
def search_entries(self, query: str, kinds: list[str] | None = None):
if self.fail_search:
@@ -74,6 +77,61 @@ class MatrixEntryService:
if value is not None:
entry[key] = value
def generate_password(self, length: int, entry_id: int) -> str:
return f"pw-{entry_id}-{length}"
def get_seed_phrase(self, entry_id: int) -> str:
entry = self._entries[int(entry_id)]
return str(entry.get("seed_phrase", "abandon " * 11 + "about")).strip()
def get_managed_account_seed(self, entry_id: int) -> str:
entry = self._entries[int(entry_id)]
return str(
entry.get(
"seed_phrase",
"legal winner thank year wave sausage worth useful legal winner thank yellow",
)
).strip()
def get_totp_secret(self, entry_id: int) -> str:
entry = self._entries[int(entry_id)]
return str(entry.get("secret", "JBSWY3DPEHPK3PXP"))
def get_totp_code(self, entry_id: int) -> str:
_ = entry_id
return "123456"
def get_ssh_key_pair(self, entry_id: int):
entry = self._entries[int(entry_id)]
return (
str(entry.get("private_key", "SSH_PRIVATE")),
str(entry.get("public_key", "ssh-ed25519 AAAA...")),
)
def get_pgp_key(self, entry_id: int):
entry = self._entries[int(entry_id)]
return (
str(entry.get("private_key", "-----BEGIN PGP PRIVATE KEY BLOCK-----")),
str(entry.get("fingerprint", "DEADBEEF")),
)
def get_nostr_key_pair(self, entry_id: int):
entry = self._entries[int(entry_id)]
return (
str(entry.get("npub", "npub1example")),
str(entry.get("nsec", "nsec1example")),
)
def get_secret_mode_enabled(self) -> bool:
return self.secret_mode_enabled
def get_clipboard_clear_delay(self) -> int:
return self.clipboard_delay
def copy_to_clipboard(self, value: str) -> bool:
self.clipboard_values.append(value)
return True
def archive_entry(self, entry_id: int) -> None:
if self.fail_archive:
raise RuntimeError("archive failed")
@@ -245,6 +303,14 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("jump 2")
await pilot.pause()
app._run_palette_command("open 2")
app.action_reveal_selected()
await pilot.pause()
assert "pw-2-" in str(app.query_one("#secret-detail").render())
app.action_show_qr()
await pilot.pause()
assert "QR not supported" in str(app.query_one("#secret-detail").render())
app.action_focus_left()
app.action_focus_center()
app.action_focus_right()
+8
View File
@@ -5,6 +5,7 @@ import pytest
from seedpass.tui_v2.app import (
pagination_window,
parse_palette_command,
render_qr_ascii,
truncate_entry_for_display,
)
@@ -61,6 +62,13 @@ def test_truncate_entry_for_display_truncates_content() -> None:
assert payload["content_truncated"] is True
def test_render_qr_ascii_nonempty() -> None:
rendered = render_qr_ascii("otpauth://totp/example?secret=JBSWY3DPEHPK3PXP")
lines = [line for line in rendered.splitlines() if line.strip()]
assert len(lines) > 4
assert any("##" in line for line in lines)
@pytest.mark.parametrize("rows", [1000, 10000, 50000])
def test_pagination_window_large_vault_sizes(rows: int) -> None:
# Large-vault smoke: pagination math remains stable at high row counts.
@@ -18,6 +18,9 @@ class FakeEntryService:
for entry in entries
}
self.fail_search_times = fail_search_times
self.secret_mode_enabled = False
self.clipboard_delay = 30
self.clipboard_values: list[str] = []
def search_entries(self, query: str, kinds: list[str] | None = None):
if self.fail_search_times > 0:
@@ -56,6 +59,61 @@ class FakeEntryService:
continue
entry[key] = value
def generate_password(self, length: int, entry_id: int) -> str:
return f"pw-{entry_id}-{length}"
def get_seed_phrase(self, entry_id: int) -> str:
entry = self._entries[int(entry_id)]
return str(entry.get("seed_phrase", "abandon " * 11 + "about")).strip()
def get_managed_account_seed(self, entry_id: int) -> str:
entry = self._entries[int(entry_id)]
return str(
entry.get(
"seed_phrase",
"legal winner thank year wave sausage worth useful legal winner thank yellow",
)
).strip()
def get_totp_secret(self, entry_id: int) -> str:
entry = self._entries[int(entry_id)]
return str(entry.get("secret", "JBSWY3DPEHPK3PXP"))
def get_totp_code(self, entry_id: int) -> str:
_ = entry_id
return "123456"
def get_ssh_key_pair(self, entry_id: int):
entry = self._entries[int(entry_id)]
return (
str(entry.get("private_key", "SSH_PRIVATE")),
str(entry.get("public_key", "ssh-ed25519 AAAA...")),
)
def get_pgp_key(self, entry_id: int):
entry = self._entries[int(entry_id)]
return (
str(entry.get("private_key", "-----BEGIN PGP PRIVATE KEY BLOCK-----")),
str(entry.get("fingerprint", "DEADBEEF")),
)
def get_nostr_key_pair(self, entry_id: int):
entry = self._entries[int(entry_id)]
return (
str(entry.get("npub", "npub1example")),
str(entry.get("nsec", "nsec1example")),
)
def get_secret_mode_enabled(self) -> bool:
return self.secret_mode_enabled
def get_clipboard_clear_delay(self) -> int:
return self.clipboard_delay
def copy_to_clipboard(self, value: str) -> bool:
self.clipboard_values.append(value)
return True
def archive_entry(self, entry_id: int) -> None:
self._entries[int(entry_id)]["archived"] = True
@@ -225,6 +283,101 @@ async def test_tui2_textual_link_commands_and_neighbor_open() -> None:
assert "No graph links" in links_text
@pytest.mark.anyio
async def test_tui2_textual_reveal_and_qr_flow() -> None:
service = FakeEntryService(
[
{"id": 1, "kind": "password", "label": "Login 1", "length": 16},
{
"id": 2,
"kind": "seed",
"label": "Seed 2",
"seed_phrase": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
},
{
"id": 3,
"kind": "totp",
"label": "TOTP 3",
"secret": "JBSWY3DPEHPK3PXP",
"period": 30,
"digits": 6,
},
]
)
app = _build_app(service)
async with app.run_test() as pilot:
await pilot.pause()
app._run_palette_command("open 1")
app.action_reveal_selected()
await pilot.pause()
assert "Password : pw-1-16" in _widget_text(app, "#secret-detail")
app._run_palette_command("open 2")
app.action_show_qr()
await pilot.pause()
qr_text = _widget_text(app, "#secret-detail")
assert "Seed #2 QR" in qr_text
assert "Payload:" in qr_text
app._run_palette_command("open 3")
app._run_palette_command("reveal")
await pilot.pause()
assert "Secret : JBSWY3DPEHPK3PXP" in _widget_text(app, "#secret-detail")
app._run_palette_command("qr")
await pilot.pause()
assert "TOTP #3 QR" in _widget_text(app, "#secret-detail")
@pytest.mark.anyio
async def test_tui2_textual_sensitive_confirm_and_secret_mode_clipboard() -> None:
service = FakeEntryService(
[
{
"id": 1,
"kind": "ssh",
"label": "SSH 1",
"private_key": "SSH_PRIVATE_1",
"public_key": "ssh-ed25519 AAAA-1",
},
{
"id": 2,
"kind": "nostr",
"label": "Nostr 2",
"npub": "npub1abc",
"nsec": "nsec1abc",
},
]
)
service.secret_mode_enabled = True
service.clipboard_delay = 45
app = _build_app(service)
async with app.run_test() as pilot:
await pilot.pause()
app._run_palette_command("open 1")
app._run_palette_command("reveal")
await pilot.pause()
assert "requires confirmation" in _status_text(app)
app._run_palette_command("reveal confirm")
await pilot.pause()
assert "copied to clipboard" in _widget_text(app, "#secret-detail")
assert service.clipboard_values[-1] == "SSH_PRIVATE_1"
app._run_palette_command("open 2")
app._run_palette_command("qr private")
await pilot.pause()
assert "requires confirmation" in _status_text(app)
app._run_palette_command("qr private confirm")
await pilot.pause()
assert "Nostr #2 QR" in _widget_text(app, "#secret-detail")
@pytest.mark.anyio
async def test_tui2_textual_retry_after_search_failure() -> None:
service = FakeEntryService(