Merge branch 'beta' into main (safe conflict resolution)

This commit is contained in:
thePR0M3TH3AN committed 2026-02-23 16:38:18 -05:00
commit 37d0dffdc3
462 files changed
+68862 -6529

No files matched your search

+145
View File
@@ -1,5 +1,40 @@
import importlib.util
import logging
import sys
from pathlib import Path
import pytest
from types import SimpleNamespace
from httpx import ASGITransport, AsyncClient
import bcrypt
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass import api
from seedpass.core.entry_types import EntryType
from helpers import (
create_vault,
TEST_PASSWORD,
TEST_SEED,
DummyBuilder,
DummyFilter,
DummyTag,
DummyTimestamp,
DummyEventId,
DummyRelayClient,
)
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 EncryptionMode, PasswordManager
from seedpass.core.encryption import EncryptionManager as EncMgr
@pytest.fixture(
params=["asyncio"] + (["trio"] if importlib.util.find_spec("trio") else [])
)
def anyio_backend(request):
return request.param
@pytest.fixture(autouse=True)
@@ -49,3 +84,113 @@ def pytest_collection_modifyitems(
for item in items:
if "desktop" in item.keywords:
item.add_marker(skip_desktop)
@pytest.fixture
def vault(tmp_path):
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
return vault
@pytest.fixture
def password_manager(vault, tmp_path):
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 = vault.encryption_manager
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.nostr_client = None
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.secret_mode_enabled = False
return pm
@pytest.fixture
async def client(monkeypatch):
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [
(1, "Site", "user", "url", False, EntryType.PASSWORD)
],
retrieve_entry=lambda i: {"label": "Site"},
add_entry=lambda *a, **k: 1,
modify_entry=lambda *a, **k: None,
archive_entry=lambda i: None,
restore_entry=lambda i: None,
),
config_manager=SimpleNamespace(
load_config=lambda require_pin=False: {"k": "v"},
set_pin=lambda v: None,
set_password_hash=lambda v: None,
set_relays=lambda v, require_pin=False: None,
set_inactivity_timeout=lambda v: None,
set_additional_backup_path=lambda v: None,
set_secret_mode_enabled=lambda v: None,
set_clipboard_clear_delay=lambda v: None,
set_quick_unlock=lambda v: None,
),
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]),
nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np")
),
verify_password=lambda pw: True,
)
monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")
token = api.start_server()
transport = ASGITransport(app=api.app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac, token
@pytest.fixture
def make_dummy_nostr_client(monkeypatch):
"""Factory to return a NostrClient wired to a DummyRelayClient."""
from cryptography.fernet import Fernet
from nostr.client import NostrClient
def _make(base_path):
relay = DummyRelayClient()
monkeypatch.setattr("nostr.client.Client", lambda signer: relay)
monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder)
monkeypatch.setattr("nostr.client.Filter", DummyFilter)
monkeypatch.setattr("nostr.client.Tag", DummyTag)
monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp)
monkeypatch.setattr("nostr.client.EventId", DummyEventId)
from nostr.backup_models import KIND_DELTA as KD
monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False)
monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None)
enc_mgr = EncMgr(Fernet.generate_key(), base_path)
class DummyKeys:
def private_key_hex(self):
return "1" * 64
def public_key_hex(self):
return "2" * 64
class DummyKeyManager:
def __init__(self, *a, **k):
self.keys = DummyKeys()
with pytest.MonkeyPatch().context() as mp:
mp.setattr("nostr.client.KeyManager", DummyKeyManager)
mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED)
client = NostrClient(enc_mgr, "fp")
return client, relay
return _make
@pytest.fixture
def dummy_nostr_client(make_dummy_nostr_client, tmp_path):
return make_dummy_nostr_client(tmp_path)
+13 -55
View File
@@ -1,18 +1,24 @@
import sys
import time
import json
import uuid
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.vault import Vault
from seedpass.core.encryption import EncryptionManager
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
)
from utils.fingerprint import generate_fingerprint
from nostr.backup_models import (
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
KIND_DELTA,
)
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
TEST_SEED = (
"abandon abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon about"
)
TEST_PASSWORD = "pw"
@@ -22,7 +28,8 @@ def create_vault(
password: str = TEST_PASSWORD,
) -> tuple[Vault, EncryptionManager]:
"""Create a Vault initialized for tests."""
seed_key = derive_key_from_password(password)
fp = generate_fingerprint(seed)
seed_key = derive_key_from_password(password, fp)
seed_mgr = EncryptionManager(seed_key, dir_path)
seed_mgr.encrypt_parent_seed(seed)
@@ -32,17 +39,6 @@ def create_vault(
return vault, enc_mgr
import uuid
import asyncio
import pytest
from nostr.backup_models import (
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
KIND_DELTA,
)
class DummyEvent:
def __init__(self, kind: int, content: str, tags=None, event_id: str | None = None):
self.kind = kind
@@ -235,41 +231,3 @@ class DummyRelayClient:
return self._evs
return Result(events)
@pytest.fixture
def dummy_nostr_client(tmp_path, monkeypatch):
"""Return a NostrClient wired to a DummyRelayClient."""
from cryptography.fernet import Fernet
from nostr.client import NostrClient
relay = DummyRelayClient()
monkeypatch.setattr("nostr.client.Client", lambda signer: relay)
monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder)
monkeypatch.setattr("nostr.client.Filter", DummyFilter)
monkeypatch.setattr("nostr.client.Tag", DummyTag)
monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp)
monkeypatch.setattr("nostr.client.EventId", DummyEventId)
from nostr.backup_models import KIND_DELTA as KD
monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False)
monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None)
enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path)
class DummyKeys:
def private_key_hex(self):
return "1" * 64
def public_key_hex(self):
return "2" * 64
class DummyKeyManager:
def __init__(self, *a, **k):
self.keys = DummyKeys()
with pytest.MonkeyPatch().context() as mp:
mp.setattr("nostr.client.KeyManager", DummyKeyManager)
mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED)
client = NostrClient(enc_mgr, "fp")
return client, relay
@@ -0,0 +1,35 @@
import builtins
from types import SimpleNamespace
import seedpass.core.manager as manager_module
from helpers import TEST_SEED
def test_add_new_fingerprint_word_entry_exits(monkeypatch):
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.fingerprint_manager = SimpleNamespace(current_fingerprint=None)
pm.initialize_managers = lambda: None
calls = {"count": 0}
original_setup = manager_module.PasswordManager.setup_existing_seed
def setup_wrapper(self, *a, **k):
calls["count"] += 1
return original_setup(self, *a, **k)
monkeypatch.setattr(
manager_module.PasswordManager, "setup_existing_seed", setup_wrapper
)
monkeypatch.setattr(manager_module, "prompt_seed_words", lambda *a, **k: TEST_SEED)
monkeypatch.setattr(
manager_module.PasswordManager,
"_finalize_existing_seed",
lambda self, seed, password=None: "fp",
)
monkeypatch.setattr(builtins, "input", lambda *_a, **_k: "2")
result = pm.add_new_fingerprint()
assert result == "fp"
assert calls["count"] == 1
assert pm.fingerprint_manager.current_fingerprint == "fp"
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import pytest
def _load_harness_module():
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "ai_tui_agent_test.py"
spec = importlib.util.spec_from_file_location("ai_tui_agent_test", script_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
harness = _load_harness_module()
def test_run_step_records_passed_result():
results = []
called = {"value": False}
def _ok():
called["value"] = True
harness._run_step("step_a", "happy path", _ok, results, verbose=False)
assert called["value"] is True
assert len(results) == 1
assert results[0].step_id == "step_a"
assert results[0].status == "passed"
assert results[0].detail is None
def test_run_step_records_failed_result_and_reraises():
results = []
def _boom():
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
harness._run_step("step_b", "failing path", _boom, results, verbose=False)
assert len(results) == 1
assert results[0].step_id == "step_b"
assert results[0].status == "failed"
assert results[0].detail == "boom"
def test_add_entry_menu_open_waits_when_choice_omitted():
class Runner:
def __init__(self):
self.sent = []
self.waited = []
def sendline(self, value=""):
self.sent.append(value)
def wait_for(self, pattern, timeout):
self.waited.append((pattern, timeout))
runner = Runner()
harness._add_entry_menu_open(runner, timeout=5.0, choice=None)
assert runner.sent == ["1"]
assert len(runner.waited) == 1
assert "Select entry type or press Enter to go back:" in runner.waited[0][0]
def test_add_entry_menu_open_sends_choice_when_provided():
class Runner:
def __init__(self):
self.sent = []
self.expect_calls = []
def sendline(self, value=""):
self.sent.append(value)
def expect_and_send(self, pattern, value, timeout):
self.expect_calls.append((pattern, value, timeout))
runner = Runner()
harness._add_entry_menu_open(runner, timeout=5.0, choice="7")
assert runner.sent == ["1"]
assert runner.expect_calls == [
("Select entry type or press Enter to go back:", "7", 5.0)
]
def test_complete_onboarding_reaches_main_menu_and_sends_password():
class Runner:
def __init__(self):
self.calls = [
(r"Enter your choice \(1-8\) or press Enter to exit:", False),
(r"Enter a new password:", True),
(r"Enter your choice \(1-8\) or press Enter to exit:", False),
(r"Enter a new password:", False),
(r"Confirm your password:", True),
(r"Enter your choice \(1-8\) or press Enter to exit:", True),
]
self.sent = []
def try_wait_for(self, pattern, timeout):
expected_pattern, ret = self.calls.pop(0)
assert pattern == expected_pattern
return ret
def sendline(self, value=""):
self.sent.append(value)
def pump(self, duration):
return None
runner = Runner()
harness._complete_onboarding_to_main_menu(runner, timeout=1.0, password="pw123")
assert runner.sent == ["pw123", "pw123"]
assert runner.calls == []
def test_complete_onboarding_times_out_without_expected_prompts():
class Runner:
def try_wait_for(self, pattern, timeout):
return False
def sendline(self, value=""):
return None
def pump(self, duration):
return None
runner = Runner()
with pytest.raises(harness.TUIHarnessError, match="Onboarding did not reach main"):
harness._complete_onboarding_to_main_menu(runner, timeout=0.0, password="pw")
def test_drain_to_main_menu_uses_helper_prompt_then_returns():
class Runner:
def __init__(self):
self.main_attempts = 0
self.helper_calls = []
def wait_for(self, pattern, timeout):
self.main_attempts += 1
if self.main_attempts == 1:
raise harness.TUIHarnessError("not yet")
return None
def expect_and_send(self, pattern, value, timeout):
self.helper_calls.append((pattern, value, timeout))
return None
def pump(self, duration):
return None
runner = Runner()
harness._drain_to_main_menu(runner, timeout=1.0)
assert runner.main_attempts == 2
assert len(runner.helper_calls) == 1
assert runner.helper_calls[0][1] == ""
def test_drain_to_main_menu_times_out_when_no_prompt_progress(monkeypatch):
class Runner:
def wait_for(self, pattern, timeout):
raise harness.TUIHarnessError("missing")
def expect_and_send(self, pattern, value, timeout):
raise harness.TUIHarnessError("missing helper")
def pump(self, duration):
return None
timeline = iter([0.0, 0.05, 0.15])
monkeypatch.setattr(harness.time, "monotonic", lambda: next(timeline))
with pytest.raises(
harness.TUIHarnessError, match="Unable to return to main menu within timeout"
):
harness._drain_to_main_menu(Runner(), timeout=0.1)
+69 -81
View File
@@ -1,63 +1,49 @@
from types import SimpleNamespace
from pathlib import Path
import sys
import pytest
from fastapi.testclient import TestClient
import bcrypt
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass import api
from seedpass.core.entry_types import EntryType
@pytest.fixture
def client(monkeypatch):
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [
(1, "Site", "user", "url", False, EntryType.PASSWORD)
],
retrieve_entry=lambda i: {"label": "Site"},
add_entry=lambda *a, **k: 1,
modify_entry=lambda *a, **k: None,
archive_entry=lambda i: None,
restore_entry=lambda i: None,
),
config_manager=SimpleNamespace(
load_config=lambda require_pin=False: {"k": "v"},
set_pin=lambda v: None,
set_password_hash=lambda v: None,
set_relays=lambda v, require_pin=False: None,
set_inactivity_timeout=lambda v: None,
set_additional_backup_path=lambda v: None,
set_secret_mode_enabled=lambda v: None,
set_clipboard_clear_delay=lambda v: None,
set_quick_unlock=lambda v: None,
),
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]),
nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np")
),
)
monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")
token = api.start_server()
client = TestClient(api.app)
return client, token
def test_get_pm_requires_initialized_state():
class DummyState:
pass
class DummyApp:
state = DummyState()
class DummyRequest:
app = DummyApp()
with pytest.raises(api.HTTPException) as exc:
api._get_pm(DummyRequest())
assert exc.value.status_code == 503
def test_cors_and_auth(client):
@pytest.mark.anyio
async def test_token_hashed(client):
_, token = client
assert api.app.state.token_hash != token
assert bcrypt.checkpw(token.encode(), api.app.state.token_hash)
@pytest.mark.anyio
async def test_cors_and_auth(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.get("/api/v1/entry", params={"query": "s"}, headers=headers)
res = await cl.get("/api/v1/entry", params={"query": "s"}, headers=headers)
assert res.status_code == 200
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_invalid_token(client):
@pytest.mark.anyio
async def test_invalid_token(client):
cl, _token = client
res = cl.get(
res = await cl.get(
"/api/v1/entry",
params={"query": "s"},
headers={"Authorization": "Bearer bad"},
@@ -65,69 +51,65 @@ def test_invalid_token(client):
assert res.status_code == 401
def test_missing_token(client):
cl, _token = client
# No Authorization header
res = cl.get(
"/api/v1/entry",
params={"query": "s"},
)
assert res.status_code == 401
def test_get_entry_by_id(client):
@pytest.mark.anyio
async def test_get_entry_by_id(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
"X-SeedPass-Password": "pw",
}
res = cl.get("/api/v1/entry/1", headers=headers)
res = await cl.get("/api/v1/entry/1", headers=headers)
assert res.status_code == 200
assert res.json() == {"label": "Site"}
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_get_config_value(client):
@pytest.mark.anyio
async def test_get_config_value(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/config/k", headers=headers)
res = await cl.get("/api/v1/config/k", headers=headers)
assert res.status_code == 200
assert res.json() == {"key": "k", "value": "v"}
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_list_fingerprint(client):
@pytest.mark.anyio
async def test_list_fingerprint(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/fingerprint", headers=headers)
res = await cl.get("/api/v1/fingerprint", headers=headers)
assert res.status_code == 200
assert res.json() == ["fp"]
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_get_nostr_pubkey(client):
@pytest.mark.anyio
async def test_get_nostr_pubkey(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/nostr/pubkey", headers=headers)
res = await cl.get("/api/v1/nostr/pubkey", headers=headers)
assert res.status_code == 200
assert res.json() == {"npub": "np"}
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_create_modify_archive_entry(client):
@pytest.mark.anyio
async def test_create_modify_archive_entry(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.post(
res = await cl.post(
"/api/v1/entry",
json={"label": "test", "length": 12},
headers=headers,
@@ -135,7 +117,7 @@ def test_create_modify_archive_entry(client):
assert res.status_code == 200
assert res.json() == {"id": 1}
res = cl.put(
res = await cl.put(
"/api/v1/entry/1",
json={"username": "bob"},
headers=headers,
@@ -143,25 +125,26 @@ def test_create_modify_archive_entry(client):
assert res.status_code == 200
assert res.json() == {"status": "ok"}
res = cl.post("/api/v1/entry/1/archive", headers=headers)
res = await cl.post("/api/v1/entry/1/archive", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "archived"}
res = cl.post("/api/v1/entry/1/unarchive", headers=headers)
res = await cl.post("/api/v1/entry/1/unarchive", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "active"}
def test_update_config(client):
@pytest.mark.anyio
async def test_update_config(client):
cl, token = client
called = {}
def set_timeout(val):
called["val"] = val
api._pm.config_manager.set_inactivity_timeout = set_timeout
api.app.state.pm.config_manager.set_inactivity_timeout = set_timeout
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.put(
res = await cl.put(
"/api/v1/config/inactivity_timeout",
json={"value": 42},
headers=headers,
@@ -172,13 +155,15 @@ def test_update_config(client):
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_update_config_quick_unlock(client):
@pytest.mark.anyio
async def test_update_config_quick_unlock(client):
cl, token = client
called = {}
api._pm.config_manager.set_quick_unlock = lambda v: called.setdefault("val", v)
api.app.state.pm.config_manager.set_quick_unlock = lambda v: called.setdefault(
"val", v
)
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.put(
res = await cl.put(
"/api/v1/config/quick_unlock",
json={"value": True},
headers=headers,
@@ -188,13 +173,13 @@ def test_update_config_quick_unlock(client):
assert called.get("val") is True
def test_change_password_route(client):
@pytest.mark.anyio
async def test_change_password_route(client):
cl, token = client
called = {}
api._pm.change_password = lambda o, n: called.setdefault("called", (o, n))
api.app.state.pm.change_password = lambda o, n: called.setdefault("called", (o, n))
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.post(
res = await cl.post(
"/api/v1/change-password",
headers=headers,
json={"old": "old", "new": "new"},
@@ -205,10 +190,11 @@ def test_change_password_route(client):
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_update_config_unknown_key(client):
@pytest.mark.anyio
async def test_update_config_unknown_key(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.put(
res = await cl.put(
"/api/v1/config/bogus",
json={"value": 1},
headers=headers,
@@ -216,7 +202,8 @@ def test_update_config_unknown_key(client):
assert res.status_code == 400
def test_shutdown(client, monkeypatch):
@pytest.mark.anyio
async def test_shutdown(client, monkeypatch):
cl, token = client
calls = {}
@@ -232,7 +219,7 @@ def test_shutdown(client, monkeypatch):
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.post("/api/v1/shutdown", headers=headers)
res = await cl.post("/api/v1/shutdown", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "shutting down"}
assert calls["func"] is sys.exit
@@ -240,6 +227,7 @@ def test_shutdown(client, monkeypatch):
assert res.headers.get("access-control-allow-origin") == "http://example.com"
@pytest.mark.anyio
@pytest.mark.parametrize(
"method,path",
[
@@ -257,11 +245,11 @@ def test_shutdown(client, monkeypatch):
("post", "/api/v1/vault/lock"),
],
)
def test_invalid_token_other_endpoints(client, method, path):
async def test_invalid_token_other_endpoints(client, method, path):
cl, _token = client
req = getattr(cl, method)
kwargs = {"headers": {"Authorization": "Bearer bad"}}
if method in {"post", "put"}:
kwargs["json"] = {}
res = req(path, **kwargs)
res = await req(path, **kwargs)
assert res.status_code == 401
+245 -125
View File
@@ -1,16 +1,18 @@
from types import SimpleNamespace
from pathlib import Path
import os
import base64
import pytest
from seedpass import api
from test_api import client
from helpers import dummy_nostr_client
import string
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
from seedpass.core.encryption import EncryptionManager
from nostr.client import NostrClient, DEFAULT_RELAYS
def test_create_and_modify_totp_entry(client):
@pytest.mark.anyio
async def test_create_and_modify_totp_entry(client):
cl, token = client
calls = {}
@@ -21,13 +23,13 @@ def test_create_and_modify_totp_entry(client):
def modify(idx, **kwargs):
calls["modify"] = (idx, kwargs)
api._pm.entry_manager.add_totp = add_totp
api._pm.entry_manager.modify_entry = modify
api._pm.entry_manager.get_next_index = lambda: 5
api._pm.parent_seed = "seed"
api.app.state.pm.entry_manager.add_totp = add_totp
api.app.state.pm.entry_manager.modify_entry = modify
api.app.state.pm.entry_manager.get_next_index = lambda: 5
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/entry",
json={
"type": "totp",
@@ -49,9 +51,10 @@ def test_create_and_modify_totp_entry(client):
"digits": 8,
"notes": "n",
"archived": False,
"deterministic": False,
}
res = cl.put(
res = await cl.put(
"/api/v1/entry/5",
json={"period": 90, "digits": 6},
headers=headers,
@@ -62,7 +65,8 @@ def test_create_and_modify_totp_entry(client):
assert calls["modify"][1]["digits"] == 6
def test_create_and_modify_ssh_entry(client):
@pytest.mark.anyio
async def test_create_and_modify_ssh_entry(client):
cl, token = client
calls = {}
@@ -73,12 +77,12 @@ def test_create_and_modify_ssh_entry(client):
def modify(idx, **kwargs):
calls["modify"] = (idx, kwargs)
api._pm.entry_manager.add_ssh_key = add_ssh
api._pm.entry_manager.modify_entry = modify
api._pm.parent_seed = "seed"
api.app.state.pm.entry_manager.add_ssh_key = add_ssh
api.app.state.pm.entry_manager.modify_entry = modify
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/entry",
json={"type": "ssh", "label": "S", "index": 2, "notes": "n"},
headers=headers,
@@ -87,7 +91,7 @@ def test_create_and_modify_ssh_entry(client):
assert res.json() == {"id": 2}
assert calls["create"] == {"index": 2, "notes": "n", "archived": False}
res = cl.put(
res = await cl.put(
"/api/v1/entry/2",
json={"notes": "x"},
headers=headers,
@@ -97,29 +101,31 @@ def test_create_and_modify_ssh_entry(client):
assert calls["modify"][1]["notes"] == "x"
def test_update_entry_error(client):
@pytest.mark.anyio
async def test_update_entry_error(client):
cl, token = client
def modify(*a, **k):
raise ValueError("nope")
api._pm.entry_manager.modify_entry = modify
api.app.state.pm.entry_manager.modify_entry = modify
headers = {"Authorization": f"Bearer {token}"}
res = cl.put("/api/v1/entry/1", json={"username": "x"}, headers=headers)
res = await cl.put("/api/v1/entry/1", json={"username": "x"}, headers=headers)
assert res.status_code == 400
assert res.json() == {"detail": "nope"}
def test_update_config_secret_mode(client):
@pytest.mark.anyio
async def test_update_config_secret_mode(client):
cl, token = client
called = {}
def set_secret(val):
called["val"] = val
api._pm.config_manager.set_secret_mode_enabled = set_secret
api.app.state.pm.config_manager.set_secret_mode_enabled = set_secret
headers = {"Authorization": f"Bearer {token}"}
res = cl.put(
res = await cl.put(
"/api/v1/config/secret_mode_enabled",
json={"value": True},
headers=headers,
@@ -129,24 +135,28 @@ def test_update_config_secret_mode(client):
assert called["val"] is True
def test_totp_export_endpoint(client):
@pytest.mark.anyio
async def test_totp_export_endpoint(client):
cl, token = client
api._pm.entry_manager.export_totp_entries = lambda seed: {"entries": ["x"]}
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/totp/export", headers=headers)
api.app.state.pm.entry_manager.export_totp_entries = lambda seed: {"entries": ["x"]}
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}", "X-SeedPass-Password": "pw"}
res = await cl.get("/api/v1/totp/export", headers=headers)
assert res.status_code == 200
assert res.json() == {"entries": ["x"]}
def test_totp_codes_endpoint(client):
@pytest.mark.anyio
async def test_totp_codes_endpoint(client):
cl, token = client
api._pm.entry_manager.list_entries = lambda **kw: [(0, "Email", None, None, False)]
api._pm.entry_manager.get_totp_code = lambda i, s: "123456"
api._pm.entry_manager.get_totp_time_remaining = lambda i: 30
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/totp", headers=headers)
api.app.state.pm.entry_manager.list_entries = lambda **kw: [
(0, "Email", None, None, False)
]
api.app.state.pm.entry_manager.get_totp_code = lambda i, s: "123456"
api.app.state.pm.entry_manager.get_totp_time_remaining = lambda i: 30
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}", "X-SeedPass-Password": "pw"}
res = await cl.get("/api/v1/totp", headers=headers)
assert res.status_code == 200
assert res.json() == {
"codes": [
@@ -155,49 +165,39 @@ def test_totp_codes_endpoint(client):
}
def test_parent_seed_endpoint(client, tmp_path):
@pytest.mark.anyio
async def test_parent_seed_endpoint_removed(client):
cl, token = client
api._pm.parent_seed = "seed"
called = {}
api._pm.encryption_manager = SimpleNamespace(
encrypt_and_save_file=lambda data, path: called.setdefault("path", path)
res = await cl.get(
"/api/v1/parent-seed", headers={"Authorization": f"Bearer {token}"}
)
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/parent-seed", headers=headers)
assert res.status_code == 200
assert res.json() == {"seed": "seed"}
out = tmp_path / "bk.enc"
res = cl.get("/api/v1/parent-seed", params={"file": str(out)}, headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "saved", "path": str(out)}
assert called["path"] == out
assert res.status_code == 404
def test_fingerprint_endpoints(client):
@pytest.mark.anyio
async def test_fingerprint_endpoints(client):
cl, token = client
calls = {}
api._pm.add_new_fingerprint = lambda: calls.setdefault("add", True)
api._pm.fingerprint_manager.remove_fingerprint = lambda fp: calls.setdefault(
"remove", fp
api.app.state.pm.add_new_fingerprint = lambda: calls.setdefault("add", True)
api.app.state.pm.fingerprint_manager.remove_fingerprint = (
lambda fp: calls.setdefault("remove", fp)
)
api._pm.select_fingerprint = lambda fp: calls.setdefault("select", fp)
api.app.state.pm.select_fingerprint = lambda fp: calls.setdefault("select", fp)
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/fingerprint", headers=headers)
res = await cl.post("/api/v1/fingerprint", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("add") is True
res = cl.delete("/api/v1/fingerprint/abc", headers=headers)
res = await cl.delete("/api/v1/fingerprint/abc", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "deleted"}
assert calls.get("remove") == "abc"
res = cl.post(
res = await cl.post(
"/api/v1/fingerprint/select",
json={"fingerprint": "xyz"},
headers=headers,
@@ -207,44 +207,49 @@ def test_fingerprint_endpoints(client):
assert calls.get("select") == "xyz"
def test_checksum_endpoints(client):
@pytest.mark.anyio
async def test_checksum_endpoints(client):
cl, token = client
calls = {}
api._pm.handle_verify_checksum = lambda: calls.setdefault("verify", True)
api._pm.handle_update_script_checksum = lambda: calls.setdefault("update", True)
api.app.state.pm.handle_verify_checksum = lambda: calls.setdefault("verify", True)
api.app.state.pm.handle_update_script_checksum = lambda: calls.setdefault(
"update", True
)
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/checksum/verify", headers=headers)
res = await cl.post("/api/v1/checksum/verify", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("verify") is True
res = cl.post("/api/v1/checksum/update", headers=headers)
res = await cl.post("/api/v1/checksum/update", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("update") is True
def test_vault_import_via_path(client, tmp_path):
@pytest.mark.anyio
async def test_vault_import_via_path(client, tmp_path):
cl, token = client
called = {}
def import_db(path):
called["path"] = path
api._pm.handle_import_database = import_db
api._pm.fingerprint_dir = tmp_path # Mock fingerprint_dir for resolving relative paths
api._pm.sync_vault = lambda: called.setdefault("sync", True)
file_path = tmp_path / "b.json"
api.app.state.pm.handle_import_database = import_db
api.app.state.pm.sync_vault = lambda: called.setdefault("sync", True)
api.app.state.pm.encryption_manager = SimpleNamespace(
resolve_relative_path=lambda p: p
)
file_path = tmp_path / "b.json.enc"
file_path.write_text("{}")
headers = {"Authorization": f"Bearer {token}"}
# Pass relative path, which should resolve to file_path
res = cl.post(
res = await cl.post(
"/api/v1/vault/import",
json={"path": "b.json"},
json={"path": str(file_path)},
headers=headers,
)
assert res.status_code == 200
@@ -253,21 +258,22 @@ def test_vault_import_via_path(client, tmp_path):
assert called.get("sync") is True
def test_vault_import_via_upload(client, tmp_path):
@pytest.mark.anyio
async def test_vault_import_via_upload(client, tmp_path):
cl, token = client
called = {}
def import_db(path):
called["path"] = path
api._pm.handle_import_database = import_db
api._pm.sync_vault = lambda: called.setdefault("sync", True)
api.app.state.pm.handle_import_database = import_db
api.app.state.pm.sync_vault = lambda: called.setdefault("sync", True)
file_path = tmp_path / "c.json"
file_path.write_text("{}")
headers = {"Authorization": f"Bearer {token}"}
with open(file_path, "rb") as fh:
res = cl.post(
res = await cl.post(
"/api/v1/vault/import",
files={"file": ("c.json", fh.read())},
headers=headers,
@@ -278,29 +284,94 @@ def test_vault_import_via_upload(client, tmp_path):
assert called.get("sync") is True
def test_vault_lock_endpoint(client):
@pytest.mark.anyio
async def test_vault_import_upload_too_large(client):
cl, token = client
called = {}
api.app.state.pm.handle_import_database = lambda path: called.setdefault(
"import_called", True
)
api.app.state.pm.sync_vault = lambda: called.setdefault("sync_called", True)
old_limit = api._MAX_IMPORT_BYTES
api._MAX_IMPORT_BYTES = 8
try:
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post(
"/api/v1/vault/import",
files={"file": ("big.json.enc", b"123456789")},
headers=headers,
)
finally:
api._MAX_IMPORT_BYTES = old_limit
assert res.status_code == 413
assert "exceeds max size" in res.json()["detail"]
assert called == {}
@pytest.mark.anyio
async def test_vault_import_invalid_extension(client):
cl, token = client
api.app.state.pm.handle_import_database = lambda path: None
api.app.state.pm.sync_vault = lambda: None
api.app.state.pm.encryption_manager = SimpleNamespace(
resolve_relative_path=lambda p: p
)
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post(
"/api/v1/vault/import",
json={"path": "bad.txt"},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.anyio
async def test_vault_import_path_traversal_blocked(client, tmp_path):
cl, token = client
key = base64.urlsafe_b64encode(os.urandom(32))
api.app.state.pm.encryption_manager = EncryptionManager(key, tmp_path)
api.app.state.pm.handle_import_database = lambda path: None
api.app.state.pm.sync_vault = lambda: None
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post(
"/api/v1/vault/import",
json={"path": "../evil.json.enc"},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.anyio
async def test_vault_lock_endpoint(client):
cl, token = client
called = {}
def lock():
called["locked"] = True
api._pm.locked = True
api.app.state.pm.locked = True
api._pm.lock_vault = lock
api._pm.locked = False
api.app.state.pm.lock_vault = lock
api.app.state.pm.locked = False
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/vault/lock", headers=headers)
res = await cl.post("/api/v1/vault/lock", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "locked"}
assert called.get("locked") is True
assert api._pm.locked is True
api._pm.unlock_vault = lambda pw: setattr(api._pm, "locked", False)
api._pm.unlock_vault("pw")
assert api._pm.locked is False
assert api.app.state.pm.locked is True
api.app.state.pm.unlock_vault = lambda pw: setattr(
api.app.state.pm, "locked", False
)
api.app.state.pm.unlock_vault("pw")
assert api.app.state.pm.locked is False
def test_secret_mode_endpoint(client):
@pytest.mark.anyio
async def test_secret_mode_endpoint(client):
cl, token = client
called = {}
@@ -310,11 +381,11 @@ def test_secret_mode_endpoint(client):
def set_delay(val):
called.setdefault("delay", val)
api._pm.config_manager.set_secret_mode_enabled = set_secret
api._pm.config_manager.set_clipboard_clear_delay = set_delay
api.app.state.pm.config_manager.set_secret_mode_enabled = set_secret
api.app.state.pm.config_manager.set_clipboard_clear_delay = set_delay
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/secret-mode",
json={"enabled": True, "delay": 12},
headers=headers,
@@ -325,40 +396,79 @@ def test_secret_mode_endpoint(client):
assert called["delay"] == 12
def test_vault_export_endpoint(client, tmp_path):
@pytest.mark.anyio
async def test_vault_export_endpoint(client, tmp_path):
cl, token = client
out = tmp_path / "out.json"
out.write_text("data")
api._pm.handle_export_database = lambda: out
api.app.state.pm.handle_export_database = lambda *a, **k: out
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/vault/export", headers=headers)
headers = {
"Authorization": f"Bearer {token}",
"X-SeedPass-Password": "pw",
}
res = await cl.post("/api/v1/vault/export", headers=headers)
assert res.status_code == 200
assert res.content == b"data"
res = await cl.post(
"/api/v1/vault/export", headers={"Authorization": f"Bearer {token}"}
)
assert res.status_code == 401
def test_backup_parent_seed_endpoint(client, tmp_path):
@pytest.mark.anyio
async def test_backup_parent_seed_endpoint(client, tmp_path):
cl, token = client
api.app.state.pm.parent_seed = "seed"
called = {}
api.app.state.pm.encryption_manager = SimpleNamespace(
encrypt_and_save_file=lambda data, path: called.setdefault("path", path),
resolve_relative_path=lambda p: p,
)
path = Path("seed.enc")
headers = {
"Authorization": f"Bearer {token}",
"X-SeedPass-Password": "pw",
}
res = await cl.post(
"/api/v1/vault/backup-parent-seed",
json={"path": str(path), "confirm": True},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "saved", "path": str(path)}
assert called["path"] == path
def backup(path=None):
called["path"] = path
api._pm.handle_backup_reveal_parent_seed = backup
path = tmp_path / "seed.enc"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/vault/backup-parent-seed",
json={"path": str(path)},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["path"] == path
assert res.status_code == 400
def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
@pytest.mark.anyio
async def test_backup_parent_seed_path_traversal_blocked(client, tmp_path):
cl, token = client
api.app.state.pm.parent_seed = "seed"
key = base64.urlsafe_b64encode(os.urandom(32))
api.app.state.pm.encryption_manager = EncryptionManager(key, tmp_path)
headers = {
"Authorization": f"Bearer {token}",
"X-SeedPass-Password": "pw",
}
res = await cl.post(
"/api/v1/vault/backup-parent-seed",
json={"path": "../evil.enc", "confirm": True},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.anyio
async def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
cl, token = client
nostr_client, _ = dummy_nostr_client
relays = ["wss://a", "wss://b"]
@@ -371,8 +481,8 @@ def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
def set_relays(new, require_pin=False):
called["set"] = new
api._pm.config_manager.load_config = load_config
api._pm.config_manager.set_relays = set_relays
api.app.state.pm.config_manager.load_config = load_config
api.app.state.pm.config_manager.set_relays = set_relays
monkeypatch.setattr(
NostrClient,
"initialize_client_pool",
@@ -381,33 +491,34 @@ def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
monkeypatch.setattr(
nostr_client, "close_client_pool", lambda: called.setdefault("close", True)
)
api._pm.nostr_client = nostr_client
api._pm.nostr_client.relays = relays.copy()
api.app.state.pm.nostr_client = nostr_client
api.app.state.pm.nostr_client.relays = relays.copy()
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/relays", headers=headers)
res = await cl.get("/api/v1/relays", headers=headers)
assert res.status_code == 200
assert res.json() == {"relays": relays}
res = cl.post("/api/v1/relays", json={"url": "wss://c"}, headers=headers)
res = await cl.post("/api/v1/relays", json={"url": "wss://c"}, headers=headers)
assert res.status_code == 200
assert called["set"] == ["wss://a", "wss://b", "wss://c"]
api._pm.config_manager.load_config = lambda require_pin=False: {
api.app.state.pm.config_manager.load_config = lambda require_pin=False: {
"relays": ["wss://a", "wss://b", "wss://c"]
}
res = cl.delete("/api/v1/relays/2", headers=headers)
res = await cl.delete("/api/v1/relays/2", headers=headers)
assert res.status_code == 200
assert called["set"] == ["wss://a", "wss://c"]
res = cl.post("/api/v1/relays/reset", headers=headers)
res = await cl.post("/api/v1/relays/reset", headers=headers)
assert res.status_code == 200
assert called.get("init") is True
assert api._pm.nostr_client.relays == list(DEFAULT_RELAYS)
assert api.app.state.pm.nostr_client.relays == list(DEFAULT_RELAYS)
def test_generate_password_no_special_chars(client):
@pytest.mark.anyio
async def test_generate_password_no_special_chars(client):
cl, token = client
class DummyEnc:
@@ -415,14 +526,18 @@ def test_generate_password_no_special_chars(client):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes(range(bytes_len))
def derive_entropy(
self, index: int, entropy_bytes: int, app_no: int = 32
) -> bytes:
return bytes(range(entropy_bytes))
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
api._pm.parent_seed = "seed"
api.app.state.pm.password_generator = PasswordGenerator(
DummyEnc(), "seed", DummyBIP85()
)
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/password",
json={"length": 16, "include_special_chars": False},
headers=headers,
@@ -432,7 +547,8 @@ def test_generate_password_no_special_chars(client):
assert not any(c in string.punctuation for c in pw)
def test_generate_password_allowed_chars(client):
@pytest.mark.anyio
async def test_generate_password_allowed_chars(client):
cl, token = client
class DummyEnc:
@@ -440,15 +556,19 @@ def test_generate_password_allowed_chars(client):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def derive_entropy(
self, index: int, entropy_bytes: int, app_no: int = 32
) -> bytes:
return bytes((index + i) % 256 for i in range(entropy_bytes))
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
api._pm.parent_seed = "seed"
api.app.state.pm.password_generator = PasswordGenerator(
DummyEnc(), "seed", DummyBIP85()
)
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
allowed = "@$"
res = cl.post(
res = await cl.post(
"/api/v1/password",
json={"length": 16, "allowed_special_chars": allowed},
headers=headers,
+34 -21
View File
@@ -1,45 +1,58 @@
from test_api import client
from types import SimpleNamespace
import queue
import pytest
import seedpass.api as api
def test_notifications_endpoint(client):
@pytest.mark.anyio
async def test_notifications_endpoint(client):
cl, token = client
api._pm.notifications = queue.Queue()
api._pm.notifications.put(SimpleNamespace(message="m1", level="INFO"))
api._pm.notifications.put(SimpleNamespace(message="m2", level="WARNING"))
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
api.app.state.pm.notifications = queue.Queue()
api.app.state.pm.notifications.put(SimpleNamespace(message="m1", level="INFO"))
api.app.state.pm.notifications.put(SimpleNamespace(message="m2", level="WARNING"))
res = await cl.get(
"/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}
)
assert res.status_code == 200
assert res.json() == [
{"level": "INFO", "message": "m1"},
{"level": "WARNING", "message": "m2"},
]
assert api._pm.notifications.empty()
assert api.app.state.pm.notifications.empty()
def test_notifications_endpoint_clears_queue(client):
@pytest.mark.anyio
async def test_notifications_endpoint_clears_queue(client):
cl, token = client
api._pm.notifications = queue.Queue()
api._pm.notifications.put(SimpleNamespace(message="hi", level="INFO"))
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
api.app.state.pm.notifications = queue.Queue()
api.app.state.pm.notifications.put(SimpleNamespace(message="hi", level="INFO"))
res = await cl.get(
"/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}
)
assert res.status_code == 200
assert res.json() == [{"level": "INFO", "message": "hi"}]
assert api._pm.notifications.empty()
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
assert api.app.state.pm.notifications.empty()
res = await cl.get(
"/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}
)
assert res.json() == []
def test_notifications_endpoint_does_not_clear_current(client):
@pytest.mark.anyio
async def test_notifications_endpoint_does_not_clear_current(client):
cl, token = client
api._pm.notifications = queue.Queue()
api.app.state.pm.notifications = queue.Queue()
msg = SimpleNamespace(message="keep", level="INFO")
api._pm.notifications.put(msg)
api._pm._current_notification = msg
api._pm.get_current_notification = lambda: api._pm._current_notification
api.app.state.pm.notifications.put(msg)
api.app.state.pm._current_notification = msg
api.app.state.pm.get_current_notification = (
lambda: api.app.state.pm._current_notification
)
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
res = await cl.get(
"/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}
)
assert res.status_code == 200
assert res.json() == [{"level": "INFO", "message": "keep"}]
assert api._pm.notifications.empty()
assert api._pm.get_current_notification() is msg
assert api.app.state.pm.notifications.empty()
assert api.app.state.pm.get_current_notification() is msg
+5 -5
View File
@@ -1,13 +1,13 @@
from test_api import client
import pytest
def test_profile_stats_endpoint(client):
@pytest.mark.anyio
async def test_profile_stats_endpoint(client):
cl, token = client
stats = {"total_entries": 1}
# monkeypatch set _pm.get_profile_stats after client fixture started
import seedpass.api as api
api._pm.get_profile_stats = lambda: stats
res = cl.get("/api/v1/stats", headers={"Authorization": f"Bearer {token}"})
api.app.state.pm.get_profile_stats = lambda: stats
res = await cl.get("/api/v1/stats", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 200
assert res.json() == stats
+50
View File
@@ -0,0 +1,50 @@
import importlib
from pathlib import Path
from types import SimpleNamespace
import importlib
import pytest
from httpx import ASGITransport, AsyncClient
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
@pytest.mark.anyio
async def test_rate_limit_exceeded(monkeypatch):
monkeypatch.setenv("SEEDPASS_RATE_LIMIT", "2")
monkeypatch.setenv("SEEDPASS_RATE_WINDOW", "60")
import seedpass.api as api
importlib.reload(api)
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [
(1, "Site", "user", "url", False, SimpleNamespace(value="password"))
]
),
config_manager=SimpleNamespace(load_config=lambda require_pin=False: {}),
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: []),
nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np")
),
verify_password=lambda pw: True,
)
monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
token = api.start_server()
transport = ASGITransport(app=api.app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
headers = {"Authorization": f"Bearer {token}"}
for _ in range(2):
res = await client.get(
"/api/v1/entry", params={"query": "s"}, headers=headers
)
assert res.status_code == 200
res = await client.get("/api/v1/entry", params={"query": "s"}, headers=headers)
assert res.status_code == 429
bucket_keys = list(api.app.state.rate_limit_buckets.keys())
assert bucket_keys
assert all(token not in key for key in bucket_keys)
+29
View File
@@ -0,0 +1,29 @@
import logging
from types import SimpleNamespace
from seedpass import api
def test_reload_relays_logs_errors(caplog):
def close():
raise RuntimeError("close fail")
def init():
raise OSError("init fail")
pm = SimpleNamespace(
nostr_client=SimpleNamespace(
close_client_pool=close,
initialize_client_pool=init,
relays=[],
)
)
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(pm=pm)))
with caplog.at_level(logging.WARNING):
api._reload_relays(request, ["ws://relay"])
assert "Failed to close NostrClient pool" in caplog.text
assert "close fail" in caplog.text
assert "Failed to initialize NostrClient with relays" in caplog.text
assert "init fail" in caplog.text
+41
View File
@@ -193,3 +193,44 @@ def test_archived_entries_menu_hides_active(monkeypatch, capsys):
out = capsys.readouterr().out
assert "archived.com" in out
assert "active.com" not in out
def test_view_archived_entries_refreshes_after_view_action(monkeypatch):
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 = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.notifications = queue.Queue()
idx = entry_mgr.add_entry("example.com", 8)
entry_mgr.archive_entry(idx)
assert entry_mgr.retrieve_entry(idx)["archived"] is True
monkeypatch.setattr(
pm,
"show_entry_details_by_index",
lambda index: entry_mgr.restore_entry(index),
)
monkeypatch.setattr("seedpass.core.manager.pause", lambda: None)
inputs = iter([str(idx), "v"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_view_archived_entries()
assert entry_mgr.retrieve_entry(idx)["archived"] is False
note = pm.notifications.get_nowait()
assert note.level == "WARNING"
assert note.message == "No archived entries found."
+30
View File
@@ -0,0 +1,30 @@
import json
from multiprocessing import Process
from pathlib import Path
from utils.atomic_write import atomic_write
def _writer(path: Path, content: dict, loops: int) -> None:
for _ in range(loops):
atomic_write(path, lambda f: json.dump(content, f), mode="w")
def test_atomic_write_concurrent(tmp_path: Path) -> None:
"""Concurrent writers should not leave partial files."""
file_path = tmp_path / "data.json"
contents = [{"proc": i} for i in range(5)]
procs = [
Process(target=_writer, args=(file_path, content, 50)) for content in contents
]
for p in procs:
p.start()
for p in procs:
p.join()
final_text = file_path.read_text()
final_obj = json.loads(final_text)
assert final_obj in contents
+85
View File
@@ -0,0 +1,85 @@
import json
import hashlib
import hmac
import queue
from pathlib import Path
from types import SimpleNamespace
import importlib
import pytest
from seedpass.core.manager import PasswordManager, AuditLogger
import seedpass.core.manager as manager_module
def test_audit_logger_records_events(monkeypatch, tmp_path):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = "user123"
pm.profile_stack = []
pm.setup_encryption_manager = lambda *a, **k: None
pm.initialize_bip85 = lambda: None
pm.initialize_managers = lambda: None
pm.update_activity = lambda: None
pm.verify_password = lambda pw: True
pm.notifications = queue.Queue()
pm.parent_seed = "seed phrase"
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: True)
manager_module.clear_header_with_notification = lambda *a, **k: None
pm.unlock_vault(password="pw")
dest = tmp_path / "db.json.enc"
monkeypatch.setattr(manager_module, "export_backup", lambda *a, **k: dest)
pm.vault = object()
pm.backup_manager = object()
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: True)
pm.handle_export_database(dest)
confirms = iter([True, False])
monkeypatch.setattr(
"seedpass.core.manager.confirm_action", lambda *_a, **_k: next(confirms)
)
pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=lambda *a, **k: None)
pm.handle_backup_reveal_parent_seed(password="pw")
log_path = tmp_path / ".seedpass" / "audit.log"
lines = [json.loads(l) for l in log_path.read_text().splitlines()]
events = [e["event"] for e in lines]
assert "quick_unlock" in events
assert "backup_export" in events
assert "seed_reveal" in events
def _verify_chain(path: Path, key: bytes) -> bool:
prev = "0" * 64
for line in path.read_text().splitlines():
data = json.loads(line)
sig = data.pop("sig")
payload = json.dumps(data, sort_keys=True, separators=(",", ":"))
expected = hmac.new(
key, f"{prev}{payload}".encode(), hashlib.sha256
).hexdigest()
if sig != expected:
return False
prev = sig
return True
def test_audit_log_tamper_evident(monkeypatch, tmp_path):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
key = hashlib.sha256(b"seed").digest()
logger = AuditLogger(key)
logger.log("one", {})
logger.log("two", {})
log_path = tmp_path / ".seedpass" / "audit.log"
assert _verify_chain(log_path, key)
lines = log_path.read_text().splitlines()
tampered = json.loads(lines[0])
tampered["event"] = "evil"
lines[0] = json.dumps(tampered)
log_path.write_text("\n".join(lines) + "\n")
assert not _verify_chain(log_path, key)
+1
View File
@@ -15,6 +15,7 @@ def test_auto_sync_triggers_post(monkeypatch):
is_dirty=True,
last_update=time.time() - 0.2,
last_activity=time.time(),
current_fingerprint="fp",
nostr_client=SimpleNamespace(close_client_pool=lambda: None),
handle_add_password=lambda: None,
handle_retrieve_entry=lambda: None,
@@ -0,0 +1,56 @@
import logging
import queue
import seedpass.core.manager as manager_module
def _make_pm():
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.offline_mode = False
pm.notifications = queue.Queue()
pm.error_queue = queue.Queue()
pm.notify = lambda msg, level="INFO": pm.notifications.put(
manager_module.Notification(msg, level)
)
pm.nostr_client = object()
return pm
def test_start_background_sync_error(monkeypatch, caplog):
pm = _make_pm()
async def failing_sync(*_args, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(pm, "attempt_initial_sync_async", failing_sync)
monkeypatch.setattr(pm, "sync_index_from_nostr_async", failing_sync)
pm.start_background_sync()
pm._sync_task.join(timeout=1)
with caplog.at_level(logging.WARNING):
pm.poll_background_errors()
note = pm.notifications.get_nowait()
assert "boom" in note.message
assert "boom" in caplog.text
def test_start_background_relay_check_error(monkeypatch, caplog):
pm = _make_pm()
class DummyClient:
def check_relay_health(self, *_args, **_kwargs):
raise RuntimeError("relay boom")
pm.nostr_client = DummyClient()
pm.start_background_relay_check()
pm._relay_thread.join(timeout=1)
with caplog.at_level(logging.WARNING):
pm.poll_background_errors()
note = pm.notifications.get_nowait()
assert "relay boom" in note.message
assert "relay boom" in caplog.text
+1
View File
@@ -20,6 +20,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
pm.current_fingerprint = None
pm.encryption_manager = object()
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
pm.nostr_account_idx = 0
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
monkeypatch.setattr(
@@ -0,0 +1,58 @@
import asyncio
from seedpass.core.manager import PasswordManager
from seedpass.core import manager as manager_module
def test_start_background_vault_sync_thread_worker_path(monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.offline_mode = False
events = []
async def fake_sync_vault_async(*, alt_summary=None):
return {"alt_summary": alt_summary}
monkeypatch.setattr(pm, "sync_vault_async", fake_sync_vault_async)
monkeypatch.setattr(
manager_module.bus, "publish", lambda *args: events.append(args)
)
class ImmediateThread:
def __init__(self, target, daemon):
self._target = target
self.daemon = daemon
def start(self):
self._target()
monkeypatch.setattr(manager_module.threading, "Thread", ImmediateThread)
pm.start_background_vault_sync("thread-path")
assert ("sync_started",) in events
assert ("sync_finished", {"alt_summary": "thread-path"}) in events
def test_start_background_vault_sync_async_worker_path(monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.offline_mode = False
events = []
async def fake_sync_vault_async(*, alt_summary=None):
return {"alt_summary": alt_summary}
monkeypatch.setattr(pm, "sync_vault_async", fake_sync_vault_async)
monkeypatch.setattr(
manager_module.bus, "publish", lambda *args: events.append(args)
)
async def runner():
pm.start_background_vault_sync("async-path")
await asyncio.sleep(0.02)
asyncio.run(runner())
assert ("sync_started",) in events
assert ("sync_finished", {"alt_summary": "async-path"}) in events
+56
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"
+52
View File
@@ -0,0 +1,52 @@
from local_bip85.bip85 import BIP85
class DummyChild:
def PrivateKey(self):
return self
def Raw(self):
return self
def ToBytes(self):
return b"\x00" * 32
class DummyCtx:
def __init__(self):
self.last_path = None
def DerivePath(self, path: str):
self.last_path = path
return DummyChild()
def test_derivation_paths_for_entropy_lengths():
bip85 = BIP85(b"\x00" * 64)
ctx = DummyCtx()
bip85.bip32_ctx = ctx
vectors = [
(16, 12),
(24, 18),
(32, 24),
]
for entropy_bytes, word_count in vectors:
bip85.derive_entropy(
index=0,
entropy_bytes=entropy_bytes,
app_no=39,
word_count=word_count,
)
assert ctx.last_path == f"m/83696968'/39'/0'/{word_count}'/0'"
def test_default_word_count_from_entropy_bytes():
bip85 = BIP85(b"\x00" * 64)
ctx = DummyCtx()
bip85.bip32_ctx = ctx
bip85.derive_entropy(index=5, entropy_bytes=20, app_no=39)
assert ctx.last_path == "m/83696968'/39'/0'/20'/5'"
+39
View File
@@ -0,0 +1,39 @@
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from bip_utils import Bip39SeedGenerator
from local_bip85.bip85 import BIP85
from helpers import TEST_SEED
from seedpass.core.manager import PasswordManager
MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
def test_init_with_seed_bytes():
seed_bytes = Bip39SeedGenerator(TEST_SEED).Generate()
bip85 = BIP85(seed_bytes)
assert isinstance(bip85, BIP85)
def test_init_with_xprv():
bip85 = BIP85(MASTER_XPRV)
assert isinstance(bip85, BIP85)
def test_initialize_bip85_works_while_locked():
pm = PasswordManager.__new__(PasswordManager)
pm.parent_seed = TEST_SEED
pm.is_locked = True
pm._bip85_cache = {(39, 0): b"stale"}
pm.KEY_STORAGE = None
pm.KEY_INDEX = None
pm.KEY_PW_DERIVE = None
pm.KEY_TOTP_DET = None
pm.master_key = None
pm.initialize_bip85()
assert isinstance(pm.bip85, BIP85)
assert pm._bip85_cache == {}
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
def _load_module():
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "check_critical_coverage.py"
spec = importlib.util.spec_from_file_location(
"check_critical_coverage", script_path
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
coverage_gate = _load_module()
def test_parse_threshold_overrides_valid():
parsed = coverage_gate._parse_threshold_overrides(
["src/main.py=40", "src/seedpass/core/manager.py=55.5"]
)
assert parsed["src/main.py"] == 40.0
assert parsed["src/seedpass/core/manager.py"] == 55.5
@pytest.mark.parametrize(
"value",
[
"src/main.py",
"src/main.py=abc",
"src/main.py=-1",
"src/main.py=101",
"=50",
],
)
def test_parse_threshold_overrides_invalid(value):
with pytest.raises(ValueError):
coverage_gate._parse_threshold_overrides([value])
def test_find_file_payload_accepts_absolute_path_suffix():
files_payload = {
"/tmp/work/src/main.py": {"summary": {"percent_covered": 50.0}},
"/tmp/work/src/seedpass/core/manager.py": {
"summary": {"percent_covered": 60.0}
},
}
payload = coverage_gate._find_file_payload(files_payload, "src/main.py")
assert payload == {"summary": {"percent_covered": 50.0}}
def test_compute_function_coverage_reports_uncovered(tmp_path):
module_file = tmp_path / "example.py"
module_file.write_text(
"\n".join(
[
"def covered():",
" return 1",
"",
"def uncovered():",
" return 2",
"",
"class A:",
" def method(self):",
" return 3",
]
),
encoding="utf-8",
)
coverage = coverage_gate._compute_function_coverage(
module_file, executed_lines={1, 2}
)
by_name = {item.name: item for item in coverage}
assert by_name["covered"].covered is True
assert by_name["uncovered"].covered is False
assert by_name["A.method"].covered is False
+290
View File
@@ -0,0 +1,290 @@
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from typer.testing import CliRunner
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.cli import app
from seedpass.cli import agent as agent_cli
from seedpass.core.entry_types import EntryType
runner = CliRunner()
def test_agent_policy_show_defaults(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
result = runner.invoke(app, ["agent", "policy-show"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["allow_export_import"] is False
assert EntryType.PASSWORD.value in payload["allow_kinds"]
def test_agent_policy_set_persists(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
result = runner.invoke(
app,
[
"agent",
"policy-set",
"--allow-kind",
"password",
"--allow-kind",
"totp",
"--deny-private-kind",
"seed",
"--allow-export-import",
],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["allow_kinds"] == ["password", "totp"]
assert payload["deny_private_reveal"] == ["seed"]
assert payload["allow_export_import"] is True
def test_agent_init_creates_profile(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
monkeypatch.setenv("SEEDPASS_PASSWORD", "AgentPass123!")
result = runner.invoke(
app,
[
"agent",
"init",
"--seed",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["status"] == "ok"
assert payload["fingerprint"]
profile_dir = Path(payload["profile_dir"])
assert profile_dir.exists()
assert (profile_dir / "parent_seed.enc").exists()
def test_agent_init_requires_password_env(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
monkeypatch.delenv("SEEDPASS_PASSWORD", raising=False)
result = runner.invoke(
app,
[
"agent",
"init",
"--seed",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
],
)
assert result.exit_code == 2
combined = f"{result.stdout}{result.stderr}"
assert "Missing password env var" in combined
def test_agent_get_requires_fingerprint(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
result = runner.invoke(app, ["agent", "get", "example"])
assert result.exit_code == 2
def test_agent_get_password_json(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(2, "Example", "", "", False, EntryType.PASSWORD)]
def retrieve_entry(self, _i):
return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12}
def generate_password(self, _length, _index):
return "secret-pass"
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["status"] == "ok"
assert payload["fingerprint"] == "ABC123"
assert payload["secret"] == "secret-pass"
assert payload["kind"] == "password"
def test_agent_get_denied_by_policy(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(4, "Seed", "", "", False, EntryType.SEED)]
def retrieve_entry(self, _i):
return {"kind": EntryType.SEED.value, "label": "Seed"}
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {
"allow_kinds": ["password", "totp", "key_value"],
"deny_private_reveal": ["seed"],
},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "seed"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "denied"
assert payload["reason"] in {"kind_not_allowed", "private_kind_blocked"}
def test_agent_get_ambiguous_returns_error_payload(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [
(2, "Example1", "", "", False, EntryType.PASSWORD),
(3, "Example2", "", "", False, EntryType.PASSWORD),
]
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "error"
assert payload["reason"] == "ambiguous_or_missing"
assert payload["match_count"] == 2
def test_agent_get_key_value_json(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(2, "Key", "", "", False, EntryType.KEY_VALUE)]
def retrieve_entry(self, _i):
return {"kind": EntryType.KEY_VALUE.value, "label": "Key", "value": "v123"}
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["key_value"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "key"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["status"] == "ok"
assert payload["kind"] == "key_value"
assert payload["secret"] == "v123"
def test_agent_get_unsupported_kind_payload(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(4, "SSH", "", "", False, EntryType.SSH)]
def retrieve_entry(self, _i):
return {"kind": EntryType.SSH.value, "label": "SSH"}
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["ssh"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "ssh"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "error"
assert payload["reason"] == "unsupported_kind_for_agent_get"
def test_agent_policy_show_invalid_json(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
result = runner.invoke(app, ["agent", "policy-show"])
assert result.exit_code == 2
combined = f"{result.stdout}{result.stderr}"
assert "not valid JSON" in combined
def test_agent_get_denied_when_policy_invalid(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "denied"
assert payload["reason"] == "invalid_policy"
+43
View File
@@ -0,0 +1,43 @@
from typer.testing import CliRunner
from seedpass.cli import app, entry as cli_entry
from seedpass.core.entry_types import EntryType
from utils.clipboard import ClipboardUnavailableError
runner = CliRunner()
def _stub_service(ctx, raise_error=True):
class Service:
def search_entries(self, query, kinds=None):
return [(1, "label", None, None, False, EntryType.PASSWORD)]
def retrieve_entry(self, idx):
return {"type": EntryType.PASSWORD.value, "length": 12}
def generate_password(self, length, index):
if raise_error and not ctx.obj.get("no_clipboard"):
raise ClipboardUnavailableError("missing")
return "pwd"
return Service()
def test_entry_get_handles_missing_clipboard(monkeypatch):
monkeypatch.setattr(
cli_entry, "_get_entry_service", lambda ctx: _stub_service(ctx, True)
)
result = runner.invoke(app, ["entry", "get", "label"], catch_exceptions=False)
assert result.exit_code == 1
assert "no-clipboard" in result.stderr.lower()
def test_entry_get_no_clipboard_flag(monkeypatch):
monkeypatch.setattr(
cli_entry, "_get_entry_service", lambda ctx: _stub_service(ctx, True)
)
result = runner.invoke(
app, ["--no-clipboard", "entry", "get", "label"], catch_exceptions=False
)
assert result.exit_code == 0
assert "pwd" in result.stdout
+2 -2
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
runner = CliRunner()
@@ -39,7 +39,7 @@ def test_config_set_variants(monkeypatch, key, value, method, expected):
config_manager=SimpleNamespace(**{method: func}),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "set", key, value])
+3 -2
View File
@@ -5,6 +5,7 @@ from typer.testing import CliRunner
from seedpass import cli
from seedpass.cli import app
from seedpass.cli import common as cli_common
from seedpass.core.entry_types import EntryType
runner = CliRunner()
@@ -18,7 +19,7 @@ def test_cli_vault_unlock(monkeypatch):
return 0.5
pm = SimpleNamespace(unlock_vault=unlock_vault, select_fingerprint=lambda fp: None)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli.typer, "prompt", lambda *a, **k: "pw")
result = runner.invoke(app, ["vault", "unlock"])
assert result.exit_code == 0
@@ -49,7 +50,7 @@ def test_cli_entry_add_search_sync(monkeypatch):
sync_vault=lambda: {"manifest_id": "m", "chunk_ids": [], "delta_ids": []},
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
# entry add
result = runner.invoke(app, ["entry", "add", "Label"])
+9 -11
View File
@@ -8,13 +8,15 @@ sys.path.append(str(Path(__file__).resolve().parents[1] / "src"))
from typer.testing import CliRunner
from seedpass import cli
from seedpass.cli import common as cli_common
from seedpass.cli import api as cli_api
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: [
@@ -23,7 +25,7 @@ class DummyPM:
retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
get_totp_code=lambda idx, seed: "123456",
add_entry=lambda label, length, username, url, **kwargs: 1,
add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6: "totp://",
add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6, deterministic=False: "totp://",
add_ssh_key=lambda label, seed, index=None, notes="": 2,
add_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3,
add_nostr_key=lambda label, seed, index=None, notes="": 4,
@@ -40,7 +42,7 @@ class DummyPM:
)
self.parent_seed = "seed"
self.handle_display_totp_codes = lambda: None
self.handle_export_database = lambda path: None
self.handle_export_database = lambda path, **kwargs: None
self.handle_import_database = lambda path: None
self.change_password = lambda *a, **kw: None
self.lock_vault = lambda: None
@@ -75,7 +77,7 @@ class DummyPM:
set_offline_mode=lambda v: None,
get_secret_mode_enabled=lambda: True,
get_clipboard_clear_delay=lambda: 30,
get_offline_mode=lambda: False,
get_offline_mode=lambda: True,
)
self.secret_mode_enabled = True
self.clipboard_clear_delay = 30
@@ -97,13 +99,9 @@ runner = CliRunner()
def _setup(monkeypatch):
monkeypatch.setattr(cli, "PasswordManager", lambda: DummyPM())
monkeypatch.setattr(cli.uvicorn, "run", lambda *a, **kw: None)
monkeypatch.setattr(
cli.api_module,
"start_server",
lambda fingerprint=None, token=None: "token",
)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: DummyPM())
monkeypatch.setattr(cli_api.uvicorn, "run", lambda *a, **kw: None)
monkeypatch.setattr(cli_api.api_module, "start_server", lambda fp: "token")
monkeypatch.setitem(
sys.modules, "requests", SimpleNamespace(post=lambda *a, **kw: None)
)
+10 -4
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
from helpers import TEST_SEED
runner = CliRunner()
@@ -65,8 +65,14 @@ runner = CliRunner()
"--digits",
"7",
],
("Label", "seed"),
{"index": 1, "secret": "abc", "period": 45, "digits": 7},
("Label", None),
{
"index": 1,
"secret": "abc",
"period": 45,
"digits": 7,
"deterministic": False,
},
"otpauth://uri",
),
(
@@ -148,7 +154,7 @@ def test_entry_add_commands(
select_fingerprint=lambda fp: None,
start_background_vault_sync=start_background_vault_sync,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", command] + cli_args)
assert result.exit_code == 0
assert stdout in result.stdout
+35 -2
View File
@@ -17,8 +17,8 @@ def _setup_pm(tmp_path: Path):
cfg = ConfigManager(vault, tmp_path)
backup = BackupManager(tmp_path, cfg)
pm = SimpleNamespace(
handle_export_database=lambda p: export_backup(
vault, backup, p, parent_seed=TEST_SEED
handle_export_database=lambda p, encrypt=True: export_backup(
vault, backup, p, parent_seed=TEST_SEED, encrypt=encrypt
),
handle_import_database=lambda p: import_backup(
vault, backup, p, parent_seed=TEST_SEED
@@ -91,3 +91,36 @@ def test_cli_import_round_trip(monkeypatch, tmp_path):
rc = main.main(["import", "--file", str(export_path)])
assert rc == 0
assert vault.load_index() == original
def test_cli_export_import_unencrypted(monkeypatch, tmp_path):
pm, vault = _setup_pm(tmp_path)
data = {
"schema_version": 4,
"entries": {
"0": {
"label": "example",
"type": "password",
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
},
}
vault.save_index(data)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None)
export_path = tmp_path / "out.json"
rc = main.main(["export", "--file", str(export_path), "--unencrypted"])
assert rc == 0
assert export_path.exists()
vault.save_index({"schema_version": 4, "entries": {}})
rc = main.main(["import", "--file", str(export_path)])
assert rc == 0
assert vault.load_index() == data
+4 -26
View File
@@ -1,11 +1,9 @@
import importlib
import shutil
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from types import SimpleNamespace
from tests.helpers import TEST_PASSWORD, TEST_SEED
from helpers import TEST_PASSWORD, TEST_SEED
import colorama
import constants
@@ -58,31 +56,11 @@ def test_cli_integration(monkeypatch, tmp_path):
monkeypatch.setattr(manager_module.PasswordManager, "add_new_fingerprint", auto_add)
monkeypatch.setattr("builtins.input", lambda *a, **k: "1")
buf = StringIO()
with redirect_stdout(buf):
try:
cli_module.app(["fingerprint", "add"])
except SystemExit as e:
assert e.code == 0
buf.truncate(0)
buf.seek(0)
cli_module.app(["fingerprint", "add"], standalone_mode=False)
with redirect_stdout(buf):
try:
cli_module.app(["entry", "add", "Example", "--length", "8"])
except SystemExit as e:
assert e.code == 0
buf.truncate(0)
buf.seek(0)
cli_module.app(["entry", "add", "Example", "--length", "8"], standalone_mode=False)
with redirect_stdout(buf):
try:
cli_module.app(["entry", "get", "Example"])
except SystemExit as e:
assert e.code == 0
lines = [line for line in buf.getvalue().splitlines() if line.strip()]
password = lines[-1]
assert len(password.strip()) >= 8
cli_module.app(["entry", "get", "Example"], standalone_mode=False)
fm = manager_module.FingerprintManager(constants.APP_DIR)
fp = fm.current_fingerprint
+3 -3
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
class DummyService:
@@ -37,8 +37,8 @@ def test_cli_relay_crud(monkeypatch):
def pm_factory(*a, **k):
return SimpleNamespace()
monkeypatch.setattr(cli, "PasswordManager", pm_factory)
monkeypatch.setattr(cli, "NostrService", lambda pm: DummyService(relays))
monkeypatch.setattr(cli_common, "PasswordManager", pm_factory)
monkeypatch.setattr(cli_common, "NostrService", lambda pm: DummyService(relays))
result = runner.invoke(app, ["nostr", "list-relays"])
assert "1: wss://a" in result.stdout
+1 -1
View File
@@ -60,7 +60,7 @@ def test_totp_command(monkeypatch, capsys):
monkeypatch.setattr(main, "initialize_app", lambda: None)
monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None)
monkeypatch.setattr(
main, "copy_to_clipboard", lambda v, d: called.setdefault("val", v)
main, "copy_to_clipboard", lambda v, d: (called.setdefault("val", v), True)[1]
)
rc = main.main(["totp", "ex"])
assert rc == 0
+7 -7
View File
@@ -2,12 +2,12 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
runner = CliRunner()
def _make_pm(called, enabled=False):
def _make_pm(called, enabled=True):
cfg = SimpleNamespace(
get_offline_mode=lambda: enabled,
set_offline_mode=lambda v: called.setdefault("enabled", v),
@@ -23,17 +23,17 @@ def _make_pm(called, enabled=False):
def test_toggle_offline_updates(monkeypatch):
called = {}
pm = _make_pm(called)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-offline"], input="y\n")
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-offline"], input="n\n")
assert result.exit_code == 0
assert called == {"enabled": True}
assert "Offline mode enabled." in result.stdout
assert called == {"enabled": False}
assert "Offline mode disabled." in result.stdout
def test_toggle_offline_keep(monkeypatch):
called = {}
pm = _make_pm(called, enabled=True)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-offline"], input="\n")
assert result.exit_code == 0
assert called == {"enabled": True}
+3 -3
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
runner = CliRunner()
@@ -27,7 +27,7 @@ def _make_pm(called, enabled=False, delay=45):
def test_toggle_secret_mode_updates(monkeypatch):
called = {}
pm = _make_pm(called)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-secret-mode"], input="y\n10\n")
assert result.exit_code == 0
assert called == {"enabled": True, "delay": 10}
@@ -37,7 +37,7 @@ def test_toggle_secret_mode_updates(monkeypatch):
def test_toggle_secret_mode_keep(monkeypatch):
called = {}
pm = _make_pm(called, enabled=True, delay=30)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-secret-mode"], input="\n\n")
assert result.exit_code == 0
assert called == {"enabled": True, "delay": 30}
+2 -2
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
runner = CliRunner()
@@ -16,7 +16,7 @@ def test_vault_stats_command(monkeypatch):
pm = SimpleNamespace(
get_profile_stats=lambda: stats, select_fingerprint=lambda fp: None
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["vault", "stats"])
assert result.exit_code == 0
out = result.stdout
+16 -1
View File
@@ -1,12 +1,15 @@
from pathlib import Path
import pyperclip
import threading
import shutil
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from utils.clipboard import copy_to_clipboard
import pytest
from utils.clipboard import ClipboardUnavailableError, copy_to_clipboard
def test_copy_to_clipboard_clears(monkeypatch):
@@ -66,3 +69,15 @@ def test_copy_to_clipboard_does_not_clear_if_changed(monkeypatch):
fake_copy("other")
callbacks["func"]()
assert clipboard["text"] == "other"
def test_copy_to_clipboard_missing_dependency(monkeypatch):
def fail_copy(*args, **kwargs):
raise pyperclip.PyperclipException("no copy")
monkeypatch.setattr(pyperclip, "copy", fail_copy)
monkeypatch.setattr(pyperclip, "paste", lambda: "")
monkeypatch.setattr(shutil, "which", lambda cmd: None)
with pytest.raises(ClipboardUnavailableError):
copy_to_clipboard("secret", 1)
+3 -1
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from multiprocessing import Process, Queue
import pytest
from helpers import TEST_SEED, TEST_PASSWORD
from utils.fingerprint import generate_fingerprint
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -51,7 +52,8 @@ def _backup(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
@pytest.mark.parametrize("_", range(3))
def test_concurrency_stress(tmp_path: Path, loops: int, _):
index_key = derive_index_key(TEST_SEED)
seed_key = derive_key_from_password(TEST_PASSWORD)
fp = generate_fingerprint(TEST_SEED)
seed_key = derive_key_from_password(TEST_PASSWORD, fp)
EncryptionManager(seed_key, tmp_path).encrypt_parent_seed(TEST_SEED)
enc = EncryptionManager(index_key, tmp_path)
Vault(enc, tmp_path).save_index({"counter": 0})
+1 -1
View File
@@ -23,7 +23,7 @@ def test_config_defaults_and_round_trip():
assert cfg["pin_hash"] == ""
assert cfg["password_hash"] == ""
assert cfg["additional_backup_path"] == ""
assert cfg["quick_unlock"] is False
assert cfg["quick_unlock_enabled"] is False
assert cfg["kdf_iterations"] == 50_000
cfg_mgr.set_pin("1234")
+529
View File
@@ -0,0 +1,529 @@
import pytest
from unittest.mock import MagicMock, patch, call
from pathlib import Path
from threading import Lock
import json
from dataclasses import dataclass
from seedpass.core.api import (
VaultService,
ProfileService,
SyncService,
EntryService,
ConfigService,
UtilityService,
NostrService,
VaultExportRequest,
VaultExportResponse,
VaultImportRequest,
ChangePasswordRequest,
UnlockRequest,
UnlockResponse,
BackupParentSeedRequest,
ProfileSwitchRequest,
ProfileRemoveRequest,
PasswordPolicyOptions,
GeneratePasswordRequest,
GeneratePasswordResponse,
AddPasswordEntryRequest,
)
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
@dataclass
class MockPolicy:
include_special_chars: bool = False
min_uppercase: int = 0
@pytest.fixture
def mock_manager():
"""Create a mock PasswordManager with all sub-components mocked."""
manager = MagicMock(spec=PasswordManager)
# Mock sub-managers
manager.entry_manager = MagicMock()
manager.config_manager = MagicMock()
manager.fingerprint_manager = MagicMock()
manager.encryption_manager = MagicMock()
manager.vault = MagicMock()
manager.password_generator = MagicMock()
manager.nostr_client = MagicMock()
manager.state_manager = MagicMock()
# Mock specific attributes
manager.KEY_TOTP_DET = b"mock_totp_key"
manager.parent_seed = "mock_parent_seed"
manager.current_fingerprint = "fp1"
return manager
@pytest.fixture
def mock_bus():
"""Mock the pubsub bus."""
with patch("seedpass.core.api.bus") as mock:
yield mock
class TestVaultService:
@pytest.fixture
def service(self, mock_manager):
return VaultService(mock_manager)
def test_export_vault(self, service, mock_manager):
req = VaultExportRequest(path=Path("/tmp/export.json"))
resp = service.export_vault(req)
mock_manager.handle_export_database.assert_called_once_with(req.path)
assert resp.path == req.path
def test_import_vault(self, service, mock_manager):
req = VaultImportRequest(path=Path("/tmp/import.json"))
service.import_vault(req)
mock_manager.handle_import_database.assert_called_once_with(req.path)
mock_manager.sync_vault.assert_called_once()
def test_export_profile(self, service, mock_manager):
mock_index = {"entries": {}}
mock_manager.vault.load_index.return_value = mock_index
mock_manager.vault.encryption_manager.encrypt_data.return_value = b"encrypted"
result = service.export_profile()
mock_manager.vault.load_index.assert_called_once()
expected_payload = json.dumps(
mock_index, sort_keys=True, separators=(",", ":")
).encode("utf-8")
mock_manager.vault.encryption_manager.encrypt_data.assert_called_once_with(
expected_payload
)
assert result == b"encrypted"
def test_import_profile(self, service, mock_manager):
data = b"encrypted_data"
decrypted_json = json.dumps({"entries": {}})
mock_manager.vault.encryption_manager.decrypt_data.return_value = (
decrypted_json.encode("utf-8")
)
service.import_profile(data)
mock_manager.vault.encryption_manager.decrypt_data.assert_called_once_with(
data, context="profile"
)
mock_manager.vault.save_index.assert_called_once_with(
json.loads(decrypted_json)
)
mock_manager.sync_vault.assert_called_once()
def test_change_password(self, service, mock_manager):
req = ChangePasswordRequest(old_password="old", new_password="new")
service.change_password(req)
mock_manager.change_password.assert_called_once_with("old", "new")
def test_unlock(self, service, mock_manager):
mock_manager.unlock_vault.return_value = 0.5
req = UnlockRequest(password="secret")
resp = service.unlock(req)
mock_manager.unlock_vault.assert_called_once_with("secret")
assert resp.duration == 0.5
def test_lock(self, service, mock_manager):
service.lock()
mock_manager.lock_vault.assert_called_once()
def test_backup_parent_seed(self, service, mock_manager):
req = BackupParentSeedRequest(path=Path("/tmp/seed.enc"), password="pw")
service.backup_parent_seed(req)
mock_manager.handle_backup_reveal_parent_seed.assert_called_once_with(
req.path, password=req.password
)
def test_stats(self, service, mock_manager):
expected_stats = {"entries": 10}
mock_manager.get_profile_stats.return_value = expected_stats
stats = service.stats()
mock_manager.get_profile_stats.assert_called_once()
assert stats == expected_stats
class TestProfileService:
@pytest.fixture
def service(self, mock_manager):
return ProfileService(mock_manager)
def test_list_profiles(self, service, mock_manager):
mock_manager.fingerprint_manager.list_fingerprints.return_value = ["fp1", "fp2"]
profiles = service.list_profiles()
mock_manager.fingerprint_manager.list_fingerprints.assert_called_once()
assert profiles == ["fp1", "fp2"]
def test_add_profile(self, service, mock_manager):
mock_manager.fingerprint_manager.current_fingerprint = "new_fp"
result = service.add_profile()
mock_manager.add_new_fingerprint.assert_called_once()
assert result == "new_fp"
def test_remove_profile(self, service, mock_manager):
req = ProfileRemoveRequest(fingerprint="fp1")
service.remove_profile(req)
mock_manager.fingerprint_manager.remove_fingerprint.assert_called_once_with(
"fp1"
)
def test_switch_profile(self, service, mock_manager):
req = ProfileSwitchRequest(fingerprint="fp2", password="pw")
service.switch_profile(req)
mock_manager.select_fingerprint.assert_called_once_with("fp2", password="pw")
class TestSyncService:
@pytest.fixture
def service(self, mock_manager):
return SyncService(mock_manager)
def test_sync(self, service, mock_manager, mock_bus):
mock_result = {"manifest_id": "m1"}
mock_manager.sync_vault.return_value = mock_result
result = service.sync()
mock_bus.publish.assert_has_calls(
[call("sync_started"), call("sync_finished", mock_result)]
)
mock_manager.sync_vault.assert_called_once()
assert result.manifest_id == "m1"
def test_sync_none(self, service, mock_manager, mock_bus):
mock_manager.sync_vault.return_value = None
result = service.sync()
mock_bus.publish.assert_has_calls(
[call("sync_started"), call("sync_finished", None)]
)
assert result is None
def test_start_background_sync(self, service, mock_manager):
service.start_background_sync()
mock_manager.start_background_sync.assert_called_once()
def test_start_background_vault_sync(self, service, mock_manager):
service.start_background_vault_sync("summary")
mock_manager.start_background_vault_sync.assert_called_once_with("summary")
class TestEntryService:
@pytest.fixture
def service(self, mock_manager):
return EntryService(mock_manager)
def test_list_entries(self, service, mock_manager):
mock_manager.entry_manager.list_entries.return_value = []
entries = service.list_entries()
mock_manager.entry_manager.list_entries.assert_called_once_with(
sort_by="index", filter_kinds=None, include_archived=False
)
assert entries == []
def test_search_entries(self, service, mock_manager):
mock_manager.entry_manager.search_entries.return_value = []
results = service.search_entries("query")
mock_manager.entry_manager.search_entries.assert_called_once_with(
"query", kinds=None
)
assert results == []
def test_retrieve_entry(self, service, mock_manager):
mock_entry = {"id": 1, "label": "test"}
mock_manager.entry_manager.retrieve_entry.return_value = mock_entry
entry = service.retrieve_entry(1)
mock_manager.entry_manager.retrieve_entry.assert_called_once_with(1)
assert entry == mock_entry
def test_generate_password_default(self, service, mock_manager):
# Case where _generate_password_for_entry is not available
del mock_manager._generate_password_for_entry
mock_manager.password_generator.generate_password.return_value = "pass123"
mock_manager.entry_manager.retrieve_entry.return_value = {}
password = service.generate_password(10, 1)
mock_manager.password_generator.generate_password.assert_called_once_with(10, 1)
assert password == "pass123"
def test_generate_password_custom(self, service, mock_manager):
# Case where _generate_password_for_entry is available
mock_manager._generate_password_for_entry = MagicMock(return_value="custom123")
mock_manager.entry_manager.retrieve_entry.return_value = {"policy": {}}
password = service.generate_password(10, 1)
mock_manager._generate_password_for_entry.assert_called_once()
assert password == "custom123"
def test_get_totp_code(self, service, mock_manager):
mock_manager.KEY_TOTP_DET = b"key"
mock_manager.entry_manager.get_totp_code.return_value = "123456"
code = service.get_totp_code(1)
mock_manager.entry_manager.get_totp_code.assert_called_once_with(1, b"key")
assert code == "123456"
def test_add_entry(self, service, mock_manager):
mock_manager.entry_manager.add_entry.return_value = 1
idx = service.add_entry("label", 12, "user", "url")
mock_manager.entry_manager.add_entry.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 1
def test_add_totp(self, service, mock_manager):
mock_manager.entry_manager.add_totp.return_value = "otpauth://..."
uri = service.add_totp("label")
mock_manager.entry_manager.add_totp.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert uri == "otpauth://..."
def test_add_ssh_key(self, service, mock_manager):
mock_manager.entry_manager.add_ssh_key.return_value = 2
idx = service.add_ssh_key("label")
mock_manager.entry_manager.add_ssh_key.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 2
def test_add_pgp_key(self, service, mock_manager):
mock_manager.entry_manager.add_pgp_key.return_value = 3
idx = service.add_pgp_key("label")
mock_manager.entry_manager.add_pgp_key.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 3
def test_add_nostr_key(self, service, mock_manager):
mock_manager.entry_manager.add_nostr_key.return_value = 4
idx = service.add_nostr_key("label")
mock_manager.entry_manager.add_nostr_key.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 4
def test_add_seed(self, service, mock_manager):
mock_manager.entry_manager.add_seed.return_value = 5
idx = service.add_seed("label")
mock_manager.entry_manager.add_seed.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 5
def test_add_key_value(self, service, mock_manager):
mock_manager.entry_manager.add_key_value.return_value = 6
idx = service.add_key_value("label", "k", "v")
mock_manager.entry_manager.add_key_value.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 6
def test_add_managed_account(self, service, mock_manager):
mock_manager.entry_manager.add_managed_account.return_value = 7
idx = service.add_managed_account("label")
mock_manager.entry_manager.add_managed_account.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 7
def test_modify_entry(self, service, mock_manager):
service.modify_entry(1, label="new_label")
mock_manager.entry_manager.modify_entry.assert_called_once_with(
1,
username=None,
url=None,
notes=None,
label="new_label",
period=None,
digits=None,
key=None,
value=None,
)
mock_manager.start_background_vault_sync.assert_called_once()
def test_archive_entry(self, service, mock_manager):
service.archive_entry(1)
mock_manager.entry_manager.archive_entry.assert_called_once_with(1)
mock_manager.start_background_vault_sync.assert_called_once()
def test_restore_entry(self, service, mock_manager):
service.restore_entry(1)
mock_manager.entry_manager.restore_entry.assert_called_once_with(1)
mock_manager.start_background_vault_sync.assert_called_once()
def test_export_totp_entries(self, service, mock_manager):
mock_manager.entry_manager.export_totp_entries.return_value = {}
result = service.export_totp_entries()
mock_manager.entry_manager.export_totp_entries.assert_called_once()
assert result == {}
def test_display_totp_codes(self, service, mock_manager):
service.display_totp_codes()
mock_manager.handle_display_totp_codes.assert_called_once()
class TestConfigService:
@pytest.fixture
def service(self, mock_manager):
return ConfigService(mock_manager)
def test_get(self, service, mock_manager):
mock_manager.config_manager.load_config.return_value = {"key": "value"}
val = service.get("key")
mock_manager.config_manager.load_config.assert_called_once_with(
require_pin=False
)
assert val == "value"
def test_set_simple(self, service, mock_manager):
service.set("inactivity_timeout", "10")
mock_manager.config_manager.set_inactivity_timeout.assert_called_once_with(10.0)
def test_set_bool(self, service, mock_manager):
service.set("secret_mode_enabled", "true")
mock_manager.config_manager.set_secret_mode_enabled.assert_called_once_with(
True
)
def test_set_relays(self, service, mock_manager):
# relays setter logic: ("set_relays", lambda v: (v, {"require_pin": False})),
# but the lambda returns a tuple (arg, kwargs)
# and then getattr(cfg, method_name)(arg, **kwargs) is called.
# Wait, the lambda returns (v, {"require_pin": False})
# So arg is v, kwargs is {"require_pin": False}
service.set("relays", ["r1"])
mock_manager.config_manager.set_relays.assert_called_once_with(
["r1"], require_pin=False
)
def test_set_invalid_key(self, service, mock_manager):
with pytest.raises(KeyError):
service.set("invalid_key", "val")
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 = 45
assert service.get_clipboard_clear_delay() == 45
def test_set_secret_mode(self, service, mock_manager):
service.set_secret_mode(True, 30)
mock_manager.config_manager.set_secret_mode_enabled.assert_called_once_with(
True
)
mock_manager.config_manager.set_clipboard_clear_delay.assert_called_once_with(
30
)
assert mock_manager.secret_mode_enabled is True
assert mock_manager.clipboard_clear_delay == 30
def test_get_offline_mode(self, service, mock_manager):
mock_manager.config_manager.get_offline_mode.return_value = False
assert service.get_offline_mode() is False
def test_set_offline_mode(self, service, mock_manager):
service.set_offline_mode(True)
mock_manager.config_manager.set_offline_mode.assert_called_once_with(True)
assert mock_manager.offline_mode is True
class TestUtilityService:
@pytest.fixture
def service(self, mock_manager):
return UtilityService(mock_manager)
def test_generate_password(self, service, mock_manager):
mock_manager.password_generator.generate_password.return_value = "pw"
# Mock policy with a real dataclass
base_policy = MockPolicy(include_special_chars=False, min_uppercase=0)
mock_manager.password_generator.policy = base_policy
result = service.generate_password(
10, include_special_chars=True, min_uppercase=5
)
mock_manager.password_generator.generate_password.assert_called_once_with(10)
assert result == "pw"
# Verify policy was restored
assert mock_manager.password_generator.policy == base_policy
def test_verify_checksum(self, service, mock_manager):
service.verify_checksum()
mock_manager.handle_verify_checksum.assert_called_once()
def test_update_checksum(self, service, mock_manager):
service.update_checksum()
mock_manager.handle_update_script_checksum.assert_called_once()
class TestNostrService:
@pytest.fixture
def service(self, mock_manager):
return NostrService(mock_manager)
def test_get_pubkey(self, service, mock_manager):
mock_manager.nostr_client.key_manager.get_npub.return_value = "npub1..."
assert service.get_pubkey() == "npub1..."
def test_list_relays(self, service, mock_manager):
mock_manager.state_manager.list_relays.return_value = ["r1"]
assert service.list_relays() == ["r1"]
def test_add_relay(self, service, mock_manager):
mock_manager.state_manager.list_relays.return_value = ["r1", "r2"]
service.add_relay("r2")
mock_manager.state_manager.add_relay.assert_called_once_with("r2")
assert mock_manager.nostr_client.relays == ["r1", "r2"]
def test_remove_relay(self, service, mock_manager):
mock_manager.state_manager.list_relays.return_value = []
service.remove_relay(0)
mock_manager.state_manager.remove_relay.assert_called_once_with(0)
assert mock_manager.nostr_client.relays == []
+20 -43
View File
@@ -1,53 +1,30 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.config_manager import ConfigManager
def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys, password_manager):
pm = password_manager
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw")
pm.nostr_client = SimpleNamespace()
def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys):
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.entry_manager.add_entry(
"example",
8,
custom_fields=[
{"label": "visible", "value": "shown", "is_hidden": False},
{"label": "token", "value": "secret", "is_hidden": True},
],
)
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.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw")
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
inputs = iter(["0", "y", "", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
entry_mgr.add_entry(
"example",
8,
custom_fields=[
{"label": "visible", "value": "shown", "is_hidden": False},
{"label": "token", "value": "secret", "is_hidden": True},
],
)
inputs = iter(["0", "y", "", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()
out = capsys.readouterr().out
assert "Additional Fields:" in out
assert "visible: shown" in out
assert "token" in out
assert "secret" in out
pm.handle_retrieve_entry()
out = capsys.readouterr().out
assert "Additional Fields:" in out
assert "visible: shown" in out
assert "token" in out
assert "secret" in out
+58
View File
@@ -0,0 +1,58 @@
import base64
import hashlib
import unicodedata
import pytest
from cryptography.fernet import InvalidToken
from helpers import TEST_PASSWORD, TEST_SEED
from seedpass.core.encryption import (
EncryptionManager,
LegacyFormatRequiresMigrationError,
)
from utils.key_derivation import derive_index_key
def test_wrong_password_message(tmp_path):
key = derive_index_key(TEST_SEED)
mgr = EncryptionManager(key, tmp_path)
payload = mgr.encrypt_data(b"secret")
wrong_key = bytearray(key)
wrong_key[0] ^= 1
wrong_mgr = EncryptionManager(bytes(wrong_key), tmp_path)
with pytest.raises(InvalidToken, match="invalid key or corrupt file") as exc:
wrong_mgr.decrypt_data(payload, context="index")
assert "index" in str(exc.value)
def test_legacy_file_requires_migration_message(tmp_path, monkeypatch):
def _fast_legacy_key(password: str, iterations: int = 100_000) -> bytes:
normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8")
key = hashlib.pbkdf2_hmac("sha256", normalized, b"", 1, dklen=32)
return base64.urlsafe_b64encode(key)
monkeypatch.setattr(
"seedpass.core.encryption._derive_legacy_key_from_password", _fast_legacy_key
)
legacy_key = _fast_legacy_key(TEST_PASSWORD)
legacy_mgr = EncryptionManager(legacy_key, tmp_path)
token = legacy_mgr.fernet.encrypt(b"secret")
new_mgr = EncryptionManager(derive_index_key(TEST_SEED), tmp_path)
with pytest.raises(LegacyFormatRequiresMigrationError, match="index") as exc:
new_mgr.decrypt_data(token, context="index")
assert "index" in str(exc.value)
def test_corrupted_data_message(tmp_path):
key = derive_index_key(TEST_SEED)
mgr = EncryptionManager(key, tmp_path)
payload = bytearray(mgr.encrypt_data(b"secret"))
payload[-1] ^= 0xFF
with pytest.raises(InvalidToken, match="invalid key or corrupt file") as exc:
mgr.decrypt_data(bytes(payload), context="index")
assert "index" in str(exc.value)
@@ -0,0 +1,53 @@
import pytest
from pathlib import Path
from tempfile import TemporaryDirectory
from seedpass.core.manager import PasswordManager
from utils.fingerprint_manager import FingerprintManager
from utils.fingerprint import generate_fingerprint
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
def _setup_pm(tmp_path, monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_manager = FingerprintManager(tmp_path)
pm.config_manager = type("Cfg", (), {"get_kdf_iterations": lambda self: 1})()
monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "pw")
monkeypatch.setattr("seedpass.core.manager.derive_index_key", lambda seed: b"idx")
monkeypatch.setattr(
"seedpass.core.manager.derive_key_from_password", lambda *a, **k: b"k"
)
class DummyEnc:
def __init__(self, *a, **k):
pass
def encrypt_parent_seed(self, seed):
pass
monkeypatch.setattr("seedpass.core.manager.EncryptionManager", DummyEnc)
monkeypatch.setattr("seedpass.core.manager.Vault", lambda *a, **k: object())
monkeypatch.setattr(
"seedpass.core.manager.ConfigManager", lambda **k: pm.config_manager
)
monkeypatch.setattr(pm, "initialize_bip85", lambda: None)
monkeypatch.setattr(pm, "initialize_managers", lambda: None)
monkeypatch.setattr(pm, "start_background_sync", lambda: None)
monkeypatch.setattr(pm, "store_hashed_password", lambda pw: None)
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_: False)
return pm
def test_duplicate_seed_profile_creation(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm = _setup_pm(tmp_path, monkeypatch)
fp1 = pm._finalize_existing_seed(VALID_SEED, password="pw")
assert fp1 == generate_fingerprint(VALID_SEED)
assert pm.fingerprint_manager.list_fingerprints() == [fp1]
fp2 = pm._finalize_existing_seed(VALID_SEED, password="pw")
assert fp2 is None
assert pm.fingerprint_manager.list_fingerprints() == [fp1]
+10 -33
View File
@@ -1,49 +1,26 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.config_manager import ConfigManager
class FakePasswordGenerator:
def generate_password(self, length: int, index: int) -> str: # noqa: D401
return "pw"
def test_edit_tags_from_retrieve(monkeypatch):
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)
def test_edit_tags_from_retrieve(monkeypatch, password_manager):
pm = password_manager
pm.password_generator = FakePasswordGenerator()
pm.nostr_client = SimpleNamespace()
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.password_generator = FakePasswordGenerator()
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
index = pm.entry_manager.add_entry("example.com", 8, tags=["old"])
index = entry_mgr.add_entry("example.com", 8, tags=["old"])
inputs = iter([str(index), "t", "newtag", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
inputs = iter([str(index), "t", "newtag", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()
pm.handle_retrieve_entry()
entry = entry_mgr.retrieve_entry(index)
assert entry.get("tags", []) == ["newtag"]
entry = pm.entry_manager.retrieve_entry(index)
assert entry.get("tags", []) == ["newtag"]
+23 -1
View File
@@ -1,5 +1,6 @@
import re
import sys
import hashlib
from pathlib import Path
from tempfile import TemporaryDirectory
@@ -23,7 +24,7 @@ def test_encryption_checksum_workflow():
manager.update_checksum()
enc_file = tmp_path / "seedpass_entries_db.json.enc"
chk_file = tmp_path / "seedpass_entries_db.json_checksum.txt"
chk_file = tmp_path / "seedpass_entries_db_checksum.txt"
checksum = chk_file.read_text().strip()
assert re.fullmatch(r"[0-9a-f]{64}", checksum)
@@ -33,3 +34,24 @@ def test_encryption_checksum_workflow():
manager.update_checksum()
assert verify_and_update_checksum(str(enc_file), str(chk_file))
def test_update_checksum_removes_legacy(tmp_path):
key = base64.urlsafe_b64encode(os.urandom(32))
manager = EncryptionManager(key, tmp_path)
manager.save_json_data({"value": 1})
legacy = tmp_path / "seedpass_entries_db.json_checksum.txt"
legacy.write_text("legacy")
manager.update_checksum()
enc_file = tmp_path / "seedpass_entries_db.json.enc"
new_chk = tmp_path / "seedpass_entries_db_checksum.txt"
assert new_chk.exists()
assert not legacy.exists()
expected = hashlib.sha256(enc_file.read_bytes()).hexdigest()
assert new_chk.read_text() == expected
+13
View File
@@ -5,6 +5,7 @@ from tempfile import TemporaryDirectory
import os
import base64
import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -40,3 +41,15 @@ def test_encrypt_and_decrypt_file_binary_round_trip():
file_path = Path(tmpdir) / rel
raw = file_path.read_bytes()
assert raw != payload
def test_encrypt_file_rejects_traversal():
with TemporaryDirectory() as tmpdir:
key = base64.urlsafe_b64encode(os.urandom(32))
manager = EncryptionManager(key, Path(tmpdir))
with pytest.raises(ValueError):
manager.encrypt_and_save_file(b"data", Path("../evil.enc"))
with pytest.raises(ValueError):
manager.encrypt_and_save_file(b"data", Path("/abs.enc"))
+37
View File
@@ -0,0 +1,37 @@
import pytest
from pathlib import Path
from cryptography.fernet import Fernet
from hypothesis import given, strategies as st, settings, HealthCheck
from seedpass.core.encryption import EncryptionManager
@given(blob=st.binary())
@settings(
deadline=None,
max_examples=25,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_encrypt_decrypt_roundtrip(blob: bytes, tmp_path: Path) -> None:
"""Ensure arbitrary data round-trips through EncryptionManager."""
key = Fernet.generate_key()
mgr = EncryptionManager(key, tmp_path)
encrypted = mgr.encrypt_data(blob)
assert mgr.decrypt_data(encrypted) == blob
@given(blob=st.binary())
@settings(
deadline=None,
max_examples=25,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_corrupted_ciphertext_fails(blob: bytes, tmp_path: Path) -> None:
"""Corrupted ciphertext should not decrypt successfully."""
key = Fernet.generate_key()
mgr = EncryptionManager(key, tmp_path)
encrypted = bytearray(mgr.encrypt_data(blob))
if encrypted:
encrypted[0] ^= 0xFF
with pytest.raises(Exception):
mgr.decrypt_data(bytes(encrypted))
@@ -0,0 +1,30 @@
import sys
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.encryption import EncryptionManager
def make_manager(tmp_path):
key = Fernet.generate_key()
return EncryptionManager(key, tmp_path)
def test_validate_seed_valid_mnemonic(tmp_path):
manager = make_manager(tmp_path)
valid = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
is_valid, error = manager.validate_seed(valid)
assert is_valid is True
assert error is None
def test_validate_seed_invalid_mnemonic(tmp_path):
manager = make_manager(tmp_path)
invalid = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
is_valid, error = manager.validate_seed(invalid)
assert is_valid is False
assert error == "Invalid seed phrase."
+2 -2
View File
@@ -21,8 +21,8 @@ class DummyEnc:
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_manager(tmp_path: Path) -> PasswordManager:
-43
View File
@@ -1,43 +0,0 @@
import logging
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from nostr import event_handler
class SimpleEvent:
def __init__(self, id: str, created_at: int, content: str) -> None:
self.id = id
self.created_at = created_at
self.content = content
def test_handle_new_event_logs(caplog):
handler = event_handler.EventHandler()
evt = SimpleEvent("1", 0, "hello")
caplog.set_level(logging.INFO, logger=event_handler.logger.name)
handler.handle_new_event(evt)
assert (
"[New Event] ID: 1 | Created At: 1970-01-01 00:00:00 | Content: hello"
in caplog.text
)
def test_handle_new_event_error(monkeypatch, caplog):
handler = event_handler.EventHandler()
evt = SimpleEvent("2", 0, "boom")
def raise_info(*args, **kwargs):
raise RuntimeError("fail")
monkeypatch.setattr(event_handler.logger, "info", raise_info)
caplog.set_level(logging.ERROR, logger=event_handler.logger.name)
handler.handle_new_event(evt)
assert "Error handling new event: fail" in caplog.text
+1 -1
View File
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, dummy_nostr_client
from helpers import create_vault
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
+1 -1
View File
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, dummy_nostr_client
from helpers import create_vault
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
+21 -4
View File
@@ -3,12 +3,17 @@ from pathlib import Path
from hypothesis import given, strategies as st, settings, HealthCheck
from mnemonic import Mnemonic
import hashlib
import base64
import os
from utils.key_derivation import (
derive_key_from_password,
derive_key_from_password_argon2,
derive_index_key,
KdfConfig,
)
from utils.fingerprint import generate_fingerprint
from seedpass.core.encryption import EncryptionManager
cfg_values = st.one_of(
@@ -32,17 +37,29 @@ cfg_values = st.one_of(
def test_fuzz_key_round_trip(password, seed_bytes, config, mode, tmp_path: Path):
"""Ensure EncryptionManager round-trips arbitrary data."""
seed_phrase = Mnemonic("english").to_mnemonic(seed_bytes)
fp = generate_fingerprint(seed_phrase)
if mode == "argon2":
key = derive_key_from_password_argon2(
password, time_cost=1, memory_cost=8, parallelism=1
cfg = KdfConfig(
params={"time_cost": 1, "memory_cost": 8, "parallelism": 1},
salt_b64=base64.b64encode(
hashlib.sha256(fp.encode()).digest()[:16]
).decode(),
)
key = derive_key_from_password_argon2(password, cfg)
else:
key = derive_key_from_password(password, iterations=1)
key = derive_key_from_password(password, fp, iterations=1)
cfg = KdfConfig(
name="pbkdf2",
params={"iterations": 1},
salt_b64=base64.b64encode(
hashlib.sha256(fp.encode()).digest()[:16]
).decode(),
)
enc_mgr = EncryptionManager(key, tmp_path)
# Parent seed round trip
enc_mgr.encrypt_parent_seed(seed_phrase)
enc_mgr.encrypt_parent_seed(seed_phrase, kdf=cfg)
assert enc_mgr.decrypt_parent_seed() == seed_phrase
# JSON data round trip
@@ -5,8 +5,6 @@ from tempfile import TemporaryDirectory
import asyncio
import gzip
from helpers import dummy_nostr_client
def load_script():
script_path = (
@@ -0,0 +1,50 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import queue
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.manager import PasswordManager, EncryptionMode
def test_get_entry_summaries_excludes_archived_and_view_handler(monkeypatch, capsys):
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 = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.notifications = queue.Queue()
active_idx = entry_mgr.add_entry("active.com", 8)
archived_idx = entry_mgr.add_entry("archived.com", 8)
entry_mgr.archive_entry(archived_idx)
summaries = entry_mgr.get_entry_summaries()
assert [s[0] for s in summaries] == [active_idx]
for idx, _, _ in summaries:
assert entry_mgr.retrieve_entry(idx)["archived"] is False
summaries_all = entry_mgr.get_entry_summaries(include_archived=True)
assert [s[0] for s in summaries_all] == [active_idx, archived_idx]
monkeypatch.setattr("builtins.input", lambda *_: "")
pm.handle_view_archived_entries()
out = capsys.readouterr().out
assert "archived.com" in out
assert "active.com" not in out
@@ -0,0 +1,48 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
def _create_entry_manager(tmp_path: Path) -> EntryManager:
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
return EntryManager(vault, backup_mgr)
def test_get_entry_summaries_updates_label():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
em = _create_entry_manager(tmp_path)
idx = em.add_entry("old", 8)
summaries = em.get_entry_summaries()
assert summaries == [(idx, "password", "old")]
em.modify_entry(idx, label="new")
summaries = em.get_entry_summaries()
assert summaries == [(idx, "password", "new")]
def test_get_entry_summaries_updates_archive_restore():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
em = _create_entry_manager(tmp_path)
keep_idx = em.add_entry("keep", 8)
drop_idx = em.add_entry("drop", 8)
summaries = em.get_entry_summaries()
assert [s[0] for s in summaries] == [keep_idx, drop_idx]
em.archive_entry(drop_idx)
summaries = em.get_entry_summaries()
assert [s[0] for s in summaries] == [keep_idx]
em.restore_entry(drop_idx)
summaries = em.get_entry_summaries()
assert [s[0] for s in summaries] == [keep_idx, drop_idx]
+2 -2
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
+3 -1
View File
@@ -33,7 +33,9 @@ class FakeEntries:
self.added.append(("password", label, length, username, url))
return 1
def add_totp(self, label):
def add_totp(
self, label, deterministic=False, index=None, secret=None, period=30, digits=6
):
self.added.append(("totp", label))
return 1
+1 -1
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):
@@ -0,0 +1,32 @@
from main import handle_switch_fingerprint
def test_handle_switch_fingerprint_active_profile(monkeypatch, capsys):
class DummyFingerprintManager:
def __init__(self):
self.fingerprints = ["fp1", "fp2"]
def list_fingerprints(self):
return self.fingerprints
def display_name(self, fp):
return fp
class DummyPM:
def __init__(self):
self.fingerprint_manager = DummyFingerprintManager()
self.current_fingerprint = "fp1"
self.decrypted = False
def select_fingerprint(self, fingerprint):
self.decrypted = True
return True
pm = DummyPM()
monkeypatch.setattr("builtins.input", lambda _: "1")
handle_switch_fingerprint(pm)
captured = capsys.readouterr()
assert "already active" in captured.out.lower()
assert pm.decrypted is False
+3 -1
View File
@@ -4,6 +4,7 @@ from tempfile import TemporaryDirectory
import pytest
import sys
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from utils.fingerprint import generate_fingerprint
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -16,7 +17,8 @@ PASSWORD = "passw0rd"
def setup_vault(tmp: Path) -> Vault:
seed_key = derive_key_from_password(PASSWORD)
fp = generate_fingerprint(SEED)
seed_key = derive_key_from_password(PASSWORD, fp)
seed_mgr = EncryptionManager(seed_key, tmp)
seed_mgr.encrypt_parent_seed(SEED)
@@ -0,0 +1,23 @@
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from seedpass.core.manager import PasswordManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.errors import SeedPassError
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
def test_invalid_password_shows_friendly_message_once(capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
pm = PasswordManager.__new__(PasswordManager)
pm.config_manager = ConfigManager(vault, tmp_path)
pm.fingerprint_dir = tmp_path
pm.parent_seed = ""
with pytest.raises(SeedPassError):
pm.load_parent_seed(tmp_path, password="wrongpass")
captured = capsys.readouterr().out
assert captured.count("Incorrect password or corrupt file") == 1
+51
View File
@@ -0,0 +1,51 @@
import bcrypt
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import TEST_SEED, TEST_PASSWORD
from utils.fingerprint import generate_fingerprint
from utils.key_derivation import derive_index_key, derive_key_from_password
from seedpass.core.encryption import EncryptionManager
from seedpass.core.vault import Vault
from seedpass.core.config_manager import ConfigManager
from seedpass.core.manager import PasswordManager, EncryptionMode
def test_kdf_iteration_fallback(monkeypatch):
with TemporaryDirectory() as td:
tmp = Path(td)
fp_name = generate_fingerprint(TEST_SEED)
fp_dir = tmp / fp_name
fp_dir.mkdir()
seed_key = derive_key_from_password(TEST_PASSWORD, fp_name, iterations=100_000)
EncryptionManager(seed_key, fp_dir).encrypt_parent_seed(TEST_SEED)
index_key = derive_index_key(TEST_SEED)
enc_mgr = EncryptionManager(index_key, fp_dir)
vault = Vault(enc_mgr, fp_dir)
cfg_mgr = ConfigManager(vault, fp_dir)
cfg = cfg_mgr.load_config(require_pin=False)
cfg["password_hash"] = bcrypt.hashpw(
TEST_PASSWORD.encode(), bcrypt.gensalt()
).decode()
cfg["kdf_iterations"] = 100_000
cfg_mgr.save_config(cfg)
cfg["kdf_iterations"] = 50_000
cfg_mgr.save_config(cfg)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.config_manager = cfg_mgr
pm.fingerprint_dir = fp_dir
pm.current_fingerprint = fp_name
pm.verify_password = lambda pw: True
monkeypatch.setattr(
"seedpass.core.manager.prompt_existing_password", lambda *_: TEST_PASSWORD
)
assert pm.setup_encryption_manager(fp_dir, exit_on_fail=False)
assert pm.parent_seed == TEST_SEED
assert cfg_mgr.get_kdf_iterations() == 100_000
+48 -7
View File
@@ -1,4 +1,6 @@
import bcrypt
import hashlib
import base64
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
@@ -7,6 +9,7 @@ from utils.key_derivation import (
derive_key_from_password,
derive_key_from_password_argon2,
derive_index_key,
KdfConfig,
)
from seedpass.core.encryption import EncryptionManager
from seedpass.core.vault import Vault
@@ -19,11 +22,26 @@ TEST_PASSWORD = "pw"
def _setup_profile(tmp: Path, mode: str):
argon_kwargs = dict(time_cost=1, memory_cost=8, parallelism=1)
fp = tmp.name
if mode == "argon2":
seed_key = derive_key_from_password_argon2(TEST_PASSWORD, **argon_kwargs)
cfg = KdfConfig(
params=argon_kwargs,
salt_b64=base64.b64encode(
hashlib.sha256(fp.encode()).digest()[:16]
).decode(),
)
seed_key = derive_key_from_password_argon2(TEST_PASSWORD, cfg)
EncryptionManager(seed_key, tmp).encrypt_parent_seed(TEST_SEED, kdf=cfg)
else:
seed_key = derive_key_from_password(TEST_PASSWORD, iterations=1)
EncryptionManager(seed_key, tmp).encrypt_parent_seed(TEST_SEED)
seed_key = derive_key_from_password(TEST_PASSWORD, fp, iterations=1)
cfg = KdfConfig(
name="pbkdf2",
params={"iterations": 1},
salt_b64=base64.b64encode(
hashlib.sha256(fp.encode()).digest()[:16]
).decode(),
)
EncryptionManager(seed_key, tmp).encrypt_parent_seed(TEST_SEED, kdf=cfg)
index_key = derive_index_key(TEST_SEED)
enc_mgr = EncryptionManager(index_key, tmp)
@@ -44,7 +62,7 @@ def _make_pm(tmp: Path, cfg: ConfigManager):
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.config_manager = cfg
pm.fingerprint_dir = tmp
pm.current_fingerprint = "fp"
pm.current_fingerprint = tmp.name
pm.verify_password = lambda pw: True
return pm
@@ -64,9 +82,9 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch):
)
if mode == "argon2":
monkeypatch.setattr(
"seedpass.core.manager.derive_key_from_password_argon2",
lambda pw, salt=None: derive_key_from_password_argon2(
pw, salt=salt if salt is not None else b"\x00" * 16, **argon_kwargs
"seedpass.core.manager.KdfConfig",
lambda salt_b64, **_: KdfConfig(
params=argon_kwargs, salt_b64=salt_b64
),
)
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)
@@ -75,3 +93,26 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch):
)
assert pm.setup_encryption_manager(path, exit_on_fail=False)
assert pm.parent_seed == TEST_SEED
def test_kdf_param_round_trip(tmp_path):
cfg = KdfConfig(
params={"time_cost": 3, "memory_cost": 32, "parallelism": 1},
salt_b64=base64.b64encode(b"static-salt-1234").decode(),
)
key = derive_key_from_password_argon2(TEST_PASSWORD, cfg)
mgr = EncryptionManager(key, tmp_path)
mgr.encrypt_parent_seed(TEST_SEED, kdf=cfg)
stored = mgr.get_file_kdf(Path("parent_seed.enc"))
assert stored.params == cfg.params
def test_vault_kdf_migration(tmp_path):
index_key = derive_index_key(TEST_SEED)
mgr = EncryptionManager(index_key, tmp_path)
vault = Vault(mgr, tmp_path)
old_kdf = KdfConfig(name="hkdf", version=0, params={}, salt_b64="")
mgr.save_json_data({"entries": {}}, vault.index_file, kdf=old_kdf)
vault.load_index()
new_kdf = mgr.get_file_kdf(vault.index_file)
assert new_kdf.version == KdfConfig().version
+19
View File
@@ -0,0 +1,19 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.config_manager import ConfigManager
from main import handle_set_kdf_iterations
def test_kdf_strength_slider_persists(monkeypatch):
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)
pm = SimpleNamespace(config_manager=cfg_mgr)
inputs = iter(["3"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
handle_set_kdf_iterations(pm)
assert cfg_mgr.get_kdf_iterations() == 100_000
+34 -9
View File
@@ -1,25 +1,36 @@
import logging
import pytest
import logging
import hashlib
import base64
from utils.fingerprint import generate_fingerprint
from utils.key_derivation import (
derive_key_from_password,
derive_key_from_password_argon2,
derive_index_key_seed_only,
derive_index_key,
KdfConfig,
)
def test_derive_key_deterministic():
def test_pbkdf2_fingerprint_affects_key():
password = "correct horse battery staple"
key1 = derive_key_from_password(password, iterations=1)
key2 = derive_key_from_password(password, iterations=1)
fp1 = generate_fingerprint("seed one")
fp2 = generate_fingerprint("seed two")
key1 = derive_key_from_password(password, fp1, iterations=1)
key2 = derive_key_from_password(password, fp1, iterations=1)
key3 = derive_key_from_password(password, fp2, iterations=1)
assert key1 == key2
assert key1 != key3
assert len(key1) == 44
logging.info("Deterministic key derivation succeeded")
logging.info("PBKDF2 fingerprint behaviour verified")
def test_derive_key_empty_password_error():
with pytest.raises(ValueError):
derive_key_from_password("")
derive_key_from_password("", "fp")
logging.info("Empty password correctly raised ValueError")
@@ -36,9 +47,23 @@ def test_derive_index_key_seed_only():
assert derive_index_key(seed) == derive_index_key_seed_only(seed)
def test_argon2_key_deterministic():
pw = "correct horse battery staple"
k1 = derive_key_from_password_argon2(pw, time_cost=1, memory_cost=8, parallelism=1)
k2 = derive_key_from_password_argon2(pw, time_cost=1, memory_cost=8, parallelism=1)
def test_argon2_fingerprint_affects_key():
password = "correct horse battery staple"
fp1 = generate_fingerprint("seed one")
fp2 = generate_fingerprint("seed two")
cfg1 = KdfConfig(
params={"time_cost": 1, "memory_cost": 8, "parallelism": 1},
salt_b64=base64.b64encode(hashlib.sha256(fp1.encode()).digest()[:16]).decode(),
)
cfg2 = KdfConfig(
params={"time_cost": 1, "memory_cost": 8, "parallelism": 1},
salt_b64=base64.b64encode(hashlib.sha256(fp2.encode()).digest()[:16]).decode(),
)
k1 = derive_key_from_password_argon2(password, cfg1)
k2 = derive_key_from_password_argon2(password, cfg1)
k3 = derive_key_from_password_argon2(password, cfg2)
assert k1 == k2
assert k1 != k3
assert len(k1) == 44
+19
View File
@@ -0,0 +1,19 @@
import base64
from bip_utils import Bip39SeedGenerator
from utils.key_hierarchy import kd
from utils.key_derivation import derive_index_key
def test_kd_distinct_infos():
root = b"root" * 8
k1 = kd(root, b"info1")
k2 = kd(root, b"info2")
assert k1 != k2
def test_derive_index_key_matches_hierarchy():
seed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
seed_bytes = Bip39SeedGenerator(seed).Generate()
master = kd(seed_bytes, b"seedpass:v1:master")
expected = base64.urlsafe_b64encode(kd(master, b"seedpass:v1:storage"))
assert derive_index_key(seed) == expected
+20
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 == []
+43
View File
@@ -0,0 +1,43 @@
import json
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from seedpass.core.encryption import (
EncryptionManager,
LegacyFormatRequiresMigrationError,
_derive_legacy_key_from_password,
)
from seedpass.core.vault import Vault
def test_decrypt_data_raises_legacy_exception(tmp_path: Path) -> None:
key = Fernet.generate_key()
mgr = EncryptionManager(key, tmp_path)
with pytest.raises(LegacyFormatRequiresMigrationError):
mgr.decrypt_data(b"not a valid token")
def test_vault_handles_legacy_exception(tmp_path: Path, monkeypatch) -> None:
password = "secret"
legacy_key = _derive_legacy_key_from_password(password)
legacy_mgr = EncryptionManager(legacy_key, tmp_path)
payload = json.dumps(
{"schema_version": 3, "entries": {"1": {"kind": "password", "password": "x"}}}
).encode("utf-8")
legacy_bytes = legacy_mgr.fernet.encrypt(payload)
index_file = tmp_path / Vault.INDEX_FILENAME
index_file.write_bytes(legacy_bytes)
new_mgr = EncryptionManager(Fernet.generate_key(), tmp_path)
vault = Vault(new_mgr, tmp_path)
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "1")
monkeypatch.setattr(
"seedpass.core.vault.prompt_existing_password", lambda *args, **kwargs: password
)
data, migrated, last = vault.load_index(return_migration_flags=True)
assert "1" in data["entries"]
assert last is False
+391 -1
View File
@@ -1,13 +1,23 @@
import json
import base64
import hashlib
from pathlib import Path
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from utils.key_derivation import derive_index_key
from cryptography.fernet import Fernet
from types import SimpleNamespace
import asyncio
import gzip
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.vault import Vault
from seedpass.core.errors import SeedPassError
def test_legacy_index_migrates(tmp_path: Path):
def test_legacy_index_migrates(monkeypatch, tmp_path: Path):
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
@@ -33,6 +43,8 @@ def test_legacy_index_migrates(tmp_path: Path):
hashlib.sha256(enc).hexdigest()
)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "y")
loaded = vault.load_index()
assert loaded == data
@@ -40,3 +52,381 @@ def test_legacy_index_migrates(tmp_path: Path):
assert new_file.exists()
assert not legacy_file.exists()
assert not (tmp_path / "seedpass_passwords_db_checksum.txt").exists()
backup = tmp_path / "legacy_backups" / "seedpass_passwords_db.json.enc"
assert backup.exists()
def test_failed_migration_restores_legacy(monkeypatch, tmp_path: Path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
enc = Fernet(key).encrypt(json.dumps(data).encode())
legacy_file = tmp_path / "seedpass_passwords_db.json.enc"
legacy_file.write_bytes(enc)
checksum = hashlib.sha256(enc).hexdigest()
(tmp_path / "seedpass_passwords_db_checksum.txt").write_text(checksum)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "y")
def bad_load_json(_path):
raise ValueError("boom")
monkeypatch.setattr(enc_mgr, "load_json_data", bad_load_json)
with pytest.raises(RuntimeError, match="Migration failed:"):
vault.load_index()
# Legacy file restored and new index removed
assert legacy_file.exists()
assert not (tmp_path / "seedpass_entries_db.json.enc").exists()
assert (tmp_path / "seedpass_passwords_db_checksum.txt").read_text() == checksum
assert not vault.migrated_from_legacy
def test_migrated_index_has_v3_prefix(monkeypatch, tmp_path: Path):
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
enc = Fernet(key).encrypt(json.dumps(data).encode())
legacy_file = tmp_path / "seedpass_passwords_db.json.enc"
legacy_file.write_bytes(enc)
(tmp_path / "seedpass_passwords_db_checksum.txt").write_text(
hashlib.sha256(enc).hexdigest()
)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "y")
vault.load_index()
new_file = tmp_path / "seedpass_entries_db.json.enc"
payload = json.loads(new_file.read_text())
assert base64.b64decode(payload["ct"]).startswith(b"V3|")
assert vault.migrated_from_legacy
def test_legacy_index_migration_removes_strays(monkeypatch, tmp_path: Path):
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
enc = Fernet(key).encrypt(json.dumps(data).encode())
legacy_file = tmp_path / "seedpass_passwords_db.json.enc"
legacy_file.write_bytes(enc)
(tmp_path / "seedpass_passwords_db_checksum.txt").write_text(
hashlib.sha256(enc).hexdigest()
)
stray_file = tmp_path / "seedpass_passwords_db.extra.enc"
stray_file.write_text("junk")
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "y")
# First load triggers migration and removes stray legacy files
loaded = vault.load_index()
assert loaded == data
assert not stray_file.exists()
# Subsequent load should not detect any legacy files
loaded_again = vault.load_index()
assert loaded_again == data
assert (tmp_path / "seedpass_entries_db.json.enc").exists()
assert list(tmp_path.glob("seedpass_passwords_db*.enc")) == []
assert not (tmp_path / "seedpass_passwords_db_checksum.txt").exists()
def test_migration_syncs_when_confirmed(monkeypatch, tmp_path: Path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
enc = Fernet(key).encrypt(json.dumps(data).encode())
legacy_file = tmp_path / "seedpass_passwords_db.json.enc"
legacy_file.write_bytes(enc)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "y")
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = Vault(enc_mgr, tmp_path)
pm.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = tmp_path.name
pm.bip85 = SimpleNamespace()
from seedpass.core.config_manager import ConfigManager
cfg_mgr = ConfigManager(pm.vault, tmp_path)
cfg = cfg_mgr.load_config(require_pin=False)
cfg["offline_mode"] = False
cfg_mgr.save_config(cfg)
pm.config_manager = cfg_mgr
pm.offline_mode = False
calls = {"sync": 0}
pm.sync_vault = lambda *a, **k: calls.__setitem__("sync", calls["sync"] + 1) or {
"manifest_id": "m",
"chunk_ids": [],
"delta_ids": [],
}
monkeypatch.setattr(
"seedpass.core.manager.NostrClient", lambda *a, **k: SimpleNamespace()
)
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: True)
pm.initialize_managers()
assert calls["sync"] == 1
assert enc_mgr.last_migration_performed is False
def test_migration_declines_sync(monkeypatch, tmp_path: Path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
enc = Fernet(key).encrypt(json.dumps(data).encode())
legacy_file = tmp_path / "seedpass_passwords_db.json.enc"
legacy_file.write_bytes(enc)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "y")
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = Vault(enc_mgr, tmp_path)
pm.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = tmp_path.name
pm.bip85 = SimpleNamespace()
calls = {"sync": 0}
pm.sync_vault = lambda *a, **k: calls.__setitem__("sync", calls["sync"] + 1) or {
"manifest_id": "m",
"chunk_ids": [],
"delta_ids": [],
}
monkeypatch.setattr(
"seedpass.core.manager.NostrClient", lambda *a, **k: SimpleNamespace()
)
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False)
pm.initialize_managers()
assert calls["sync"] == 0
assert enc_mgr.last_migration_performed is False
def test_legacy_nostr_payload_syncs_when_confirmed(monkeypatch, tmp_path: Path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
legacy_enc = Fernet(key).encrypt(json.dumps(data).encode())
compressed = gzip.compress(legacy_enc)
class DummyClient:
def __init__(self):
self.relays = []
self.last_error = None
self.fingerprint = None
async def fetch_latest_snapshot(self):
from nostr.backup_models import Manifest
return Manifest(ver=1, algo="gzip", chunks=[], delta_since=None), [
compressed
]
async def fetch_deltas_since(self, version):
return []
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = Vault(enc_mgr, tmp_path)
pm.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = tmp_path.name
pm.nostr_client = DummyClient()
pm.offline_mode = False
calls = {"sync": 0}
async def fake_sync_vault_async(*_a, **_k):
calls["sync"] += 1
return True
pm.sync_vault_async = fake_sync_vault_async
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: True)
asyncio.run(pm.sync_index_from_nostr_async())
assert calls["sync"] == 1
assert pm.vault.load_index() == data
assert enc_mgr.last_migration_performed is False
def test_legacy_index_reinit_syncs_once_when_confirmed(monkeypatch, tmp_path: Path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
enc = Fernet(key).encrypt(json.dumps(data).encode())
legacy_file = tmp_path / "seedpass_passwords_db.json.enc"
legacy_file.write_bytes(enc)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "y")
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = Vault(enc_mgr, tmp_path)
pm.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = tmp_path.name
pm.bip85 = SimpleNamespace()
pm.offline_mode = True
monkeypatch.setattr(
"seedpass.core.manager.NostrClient", lambda *a, **k: SimpleNamespace()
)
calls = {"sync": 0}
pm.sync_vault = lambda *a, **k: calls.__setitem__("sync", calls["sync"] + 1) or {
"manifest_id": "m",
"chunk_ids": [],
"delta_ids": [],
}
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: True)
pm.initialize_managers()
pm.initialize_managers()
assert calls["sync"] == 0
assert enc_mgr.last_migration_performed is False
def test_schema_migration_no_sync_prompt(monkeypatch, tmp_path: Path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
data = {"schema_version": 3, "entries": {}}
enc_mgr.save_json_data(data, Path("seedpass_entries_db.json.enc"))
enc_mgr.update_checksum(Path("seedpass_entries_db.json.enc"))
assert enc_mgr.last_migration_performed is False
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = Vault(enc_mgr, tmp_path)
pm.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = tmp_path.name
pm.bip85 = SimpleNamespace()
from seedpass.core.config_manager import ConfigManager
cfg_mgr = ConfigManager(pm.vault, tmp_path)
cfg = cfg_mgr.load_config(require_pin=False)
cfg["offline_mode"] = False
cfg_mgr.save_config(cfg)
pm.config_manager = cfg_mgr
pm.offline_mode = False
calls = {"sync": 0, "confirm": 0}
pm.sync_vault = lambda *a, **k: calls.__setitem__("sync", calls["sync"] + 1) or {
"manifest_id": "m",
"chunk_ids": [],
"delta_ids": [],
}
monkeypatch.setattr(
"seedpass.core.manager.NostrClient", lambda *a, **k: SimpleNamespace()
)
def fake_confirm(*_a, **_k):
calls["confirm"] += 1
return True
monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm)
pm.initialize_managers()
assert calls["sync"] == 0
assert calls["confirm"] == 0
assert enc_mgr.last_migration_performed is False
def test_declined_migration_no_sync_prompt(monkeypatch, tmp_path: Path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
enc = Fernet(key).encrypt(json.dumps(data).encode())
legacy_file = tmp_path / "seedpass_passwords_db.json.enc"
legacy_file.write_bytes(enc)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "n")
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = Vault(enc_mgr, tmp_path)
pm.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = tmp_path.name
pm.bip85 = SimpleNamespace()
calls = {"confirm": 0}
def fake_confirm(*_a, **_k):
calls["confirm"] += 1
return True
monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm)
with pytest.raises(SeedPassError):
pm.initialize_managers()
assert calls["confirm"] == 0
def test_failed_migration_no_sync_prompt(monkeypatch, tmp_path: Path):
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
enc = Fernet(key).encrypt(json.dumps(data).encode())
legacy_file = tmp_path / "seedpass_passwords_db.json.enc"
legacy_file.write_bytes(enc)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "y")
def fail(*_a, **_k):
raise ValueError("boom")
monkeypatch.setattr(enc_mgr, "load_json_data", fail)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = Vault(enc_mgr, tmp_path)
pm.parent_seed = TEST_SEED
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = tmp_path.name
pm.bip85 = SimpleNamespace()
calls = {"confirm": 0}
def fake_confirm(*_a, **_k):
calls["confirm"] += 1
return True
monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm)
with pytest.raises(SeedPassError):
pm.initialize_managers()
assert calls["confirm"] == 0
@@ -0,0 +1,70 @@
import base64
import json
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
import pytest
import seedpass.core.encryption as enc_module
import seedpass.core.vault as vault_module
from helpers import TEST_PASSWORD
from seedpass.core.encryption import (
EncryptionManager,
_derive_legacy_key_from_password,
)
from seedpass.core.config_manager import ConfigManager
from seedpass.core.vault import Vault
from seedpass.core.migrations import LATEST_VERSION
def _setup_legacy_file(tmp_path: Path, iterations: int) -> None:
legacy_key = _derive_legacy_key_from_password(TEST_PASSWORD, iterations=iterations)
mgr = EncryptionManager(legacy_key, tmp_path)
data = {"schema_version": LATEST_VERSION, "entries": {"0": {"kind": "test"}}}
json_bytes = json.dumps(data, separators=(",", ":")).encode("utf-8")
legacy_encrypted = mgr.fernet.encrypt(json_bytes)
(tmp_path / "seedpass_entries_db.json.enc").write_bytes(legacy_encrypted)
@pytest.mark.parametrize("iterations", [50_000, 100_000])
def test_migrate_iterations(tmp_path, monkeypatch, iterations):
_setup_legacy_file(tmp_path, iterations)
new_key = base64.urlsafe_b64encode(b"B" * 32)
mgr = EncryptionManager(new_key, tmp_path)
vault = Vault(mgr, tmp_path)
prompts: list[int] = []
def fake_prompt(_msg: str) -> str:
prompts.append(1)
return TEST_PASSWORD
monkeypatch.setattr(enc_module, "prompt_existing_password", fake_prompt)
monkeypatch.setattr(vault_module, "prompt_existing_password", fake_prompt)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "2")
calls: list[int] = []
orig_derive = enc_module._derive_legacy_key_from_password
def tracking_derive(password: str, iterations: int = 100_000) -> bytes:
calls.append(iterations)
return orig_derive(password, iterations=iterations)
monkeypatch.setattr(enc_module, "_derive_legacy_key_from_password", tracking_derive)
vault.load_index()
# Loading again should not prompt for password or attempt legacy counts
vault.load_index()
assert prompts == [1]
expected = [50_000] if iterations == 50_000 else [50_000, 100_000]
assert calls == expected
cfg = ConfigManager(vault, tmp_path)
assert cfg.get_kdf_iterations() == iterations
payload = json.loads((tmp_path / "seedpass_entries_db.json.enc").read_text())
assert base64.b64decode(payload["ct"]).startswith(b"V3|")
+55
View File
@@ -0,0 +1,55 @@
import base64
import json
from pathlib import Path
from seedpass.core.encryption import (
EncryptionManager,
_derive_legacy_key_from_password,
)
from seedpass.core.vault import Vault
import seedpass.core.vault as vault_module
from seedpass.core.migrations import LATEST_VERSION
def _setup_legacy_file(tmp_path: Path, password: str) -> Path:
legacy_key = _derive_legacy_key_from_password(password, iterations=50_000)
legacy_mgr = EncryptionManager(legacy_key, tmp_path)
data = {"schema_version": LATEST_VERSION, "entries": {"0": {"kind": "test"}}}
json_bytes = json.dumps(data, separators=(",", ":")).encode("utf-8")
legacy_encrypted = legacy_mgr.fernet.encrypt(json_bytes)
file_path = tmp_path / "seedpass_entries_db.json.enc"
file_path.write_bytes(legacy_encrypted)
return file_path
def test_open_legacy_without_migrating(tmp_path, monkeypatch):
password = "secret"
_setup_legacy_file(tmp_path, password)
new_key = base64.urlsafe_b64encode(b"A" * 32)
mgr = EncryptionManager(new_key, tmp_path)
vault = Vault(mgr, tmp_path)
monkeypatch.setattr(
"seedpass.core.encryption.prompt_existing_password", lambda _: password
)
monkeypatch.setattr(vault_module, "prompt_existing_password", lambda _: password)
monkeypatch.setattr("builtins.input", lambda _: "1")
vault.load_index()
assert vault.encryption_manager.last_migration_performed is False
assert vault.migrated_from_legacy is False
def test_migrate_legacy_sets_flag(tmp_path, monkeypatch):
password = "secret"
_setup_legacy_file(tmp_path, password)
new_key = base64.urlsafe_b64encode(b"B" * 32)
mgr = EncryptionManager(new_key, tmp_path)
vault = Vault(mgr, tmp_path)
monkeypatch.setattr(
"seedpass.core.encryption.prompt_existing_password", lambda _: password
)
monkeypatch.setattr(vault_module, "prompt_existing_password", lambda _: password)
monkeypatch.setattr("builtins.input", lambda _: "2")
vault.load_index()
payload = json.loads((tmp_path / "seedpass_entries_db.json.enc").read_text())
assert base64.b64decode(payload["ct"]).startswith(b"V3|")
assert vault.encryption_manager.last_migration_performed is True
@@ -0,0 +1,65 @@
import json
import base64
import hashlib
from pathlib import Path
from types import SimpleNamespace
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.vault import Vault
from seedpass.core.encryption import EncryptionManager
from utils.key_derivation import derive_index_key
from utils.fingerprint import generate_fingerprint
def test_legacy_migration_second_session(monkeypatch, tmp_path: Path) -> None:
fingerprint = generate_fingerprint(TEST_SEED)
fp_dir = tmp_path / fingerprint
fp_dir.mkdir()
vault, _ = create_vault(fp_dir, TEST_SEED, TEST_PASSWORD)
key = derive_index_key(TEST_SEED)
data = {"schema_version": 4, "entries": {}}
enc = Fernet(key).encrypt(json.dumps(data).encode())
legacy_file = fp_dir / "seedpass_passwords_db.json.enc"
legacy_file.write_bytes(enc)
(fp_dir / "seedpass_passwords_db_checksum.txt").write_text(
hashlib.sha256(enc).hexdigest()
)
monkeypatch.setattr(
"seedpass.core.encryption.prompt_existing_password", lambda *_: TEST_PASSWORD
)
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "y")
vault.load_index()
new_file = fp_dir / "seedpass_entries_db.json.enc"
payload = json.loads(new_file.read_text())
assert base64.b64decode(payload["ct"]).startswith(b"V3|")
new_enc_mgr = EncryptionManager(key, fp_dir)
new_vault = Vault(new_enc_mgr, fp_dir)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = new_enc_mgr
pm.vault = new_vault
pm.parent_seed = TEST_SEED
pm.fingerprint_dir = fp_dir
pm.current_fingerprint = fp_dir.name
pm.bip85 = SimpleNamespace()
monkeypatch.setattr(
"seedpass.core.manager.NostrClient", lambda *a, **k: SimpleNamespace()
)
def fail_prompt(*_a, **_k): # pragma: no cover - ensures no prompts occur
raise AssertionError("Prompt should not be called")
monkeypatch.setattr("builtins.input", fail_prompt)
monkeypatch.setattr(
"seedpass.core.encryption.prompt_existing_password", fail_prompt
)
pm.initialize_managers()
payload = json.loads(new_file.read_text())
assert base64.b64decode(payload["ct"]).startswith(b"V3|")
+85
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
+1 -1
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)]
+24
View File
@@ -0,0 +1,24 @@
"""Tests for load_global_config failure scenarios."""
import logging
from pathlib import Path
import pytest
from main import load_global_config
def test_load_global_config_invalid_toml(monkeypatch, tmp_path, caplog):
"""Invalid TOML should log a warning and return an empty dict."""
config_dir = tmp_path / ".seedpass"
config_dir.mkdir()
config_file = config_dir / "config.toml"
config_file.write_text("invalid = [")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
with caplog.at_level(logging.WARNING):
result = load_global_config()
assert result == {}
assert "Failed to read" in caplog.text
+20
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:
+130 -2
View File
@@ -5,7 +5,7 @@ from types import SimpleNamespace
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -140,7 +140,7 @@ def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys
called = []
monkeypatch.setattr(
"seedpass.core.manager.copy_to_clipboard",
lambda text, delay: called.append((text, delay)),
lambda text, delay: (called.append((text, delay)), True)[1],
)
pm.handle_add_password()
@@ -208,3 +208,131 @@ def test_handle_add_password_quick_mode(monkeypatch, dummy_nostr_client, capsys)
}
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out
def test_handle_add_password_quick_mode_reprompts_invalid_length(
monkeypatch, dummy_nostr_client, capsys
):
client, _relay = dummy_nostr_client
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.password_generator = FakePasswordGenerator()
pm.parent_seed = TEST_SEED
pm.nostr_client = client
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
pm.is_dirty = False
inputs = iter(
[
"q",
"Example",
"",
"",
"3",
"abc",
"12",
"",
]
)
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
pm.handle_add_password()
out = capsys.readouterr().out
assert "Error: Password length must be between" in out
assert "Error: Password length must be a number." in out
entries = entry_mgr.list_entries(verbose=False)
assert entries == [(0, "Example", "", "", False)]
entry = entry_mgr.retrieve_entry(0)
assert entry["length"] == 12
def test_handle_add_password_reprompts_invalid_mode(
monkeypatch, dummy_nostr_client, capsys
):
client, _relay = dummy_nostr_client
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.password_generator = FakePasswordGenerator()
pm.parent_seed = TEST_SEED
pm.nostr_client = client
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
pm.is_dirty = False
inputs = iter(
[
"z", # invalid mode
"q", # then quick mode
"Example",
"",
"",
"",
"",
]
)
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
pm.handle_add_password()
out = capsys.readouterr().out
assert "Invalid mode. Enter 'q' for quick or 'a' for advanced." in out
entries = entry_mgr.list_entries(verbose=False)
assert entries == [(0, "Example", "", "", False)]
def test_handle_add_password_empty_mode_cancels(monkeypatch, dummy_nostr_client):
client, _relay = dummy_nostr_client
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.password_generator = FakePasswordGenerator()
pm.parent_seed = TEST_SEED
pm.nostr_client = client
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
pm.is_dirty = False
inputs = iter([""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
pm.handle_add_password()
assert entry_mgr.list_entries(verbose=False) == []
+7 -11
View File
@@ -60,15 +60,11 @@ def test_handle_add_totp(monkeypatch, capsys):
out = capsys.readouterr().out
entry = entry_mgr.retrieve_entry(0)
assert entry == {
"type": "totp",
"kind": "totp",
"label": "Example",
"index": 0,
"period": 30,
"digits": 6,
"archived": False,
"notes": "",
"tags": [],
}
assert entry["type"] == "totp"
assert entry["kind"] == "totp"
assert entry["label"] == "Example"
assert entry["deterministic"] is False
assert "index" not in entry
assert "secret" in entry
assert len(entry["secret"]) >= 16
assert "ID 0" in out
+39 -75
View File
@@ -1,16 +1,10 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from helpers import TEST_SEED
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.config_manager import ConfigManager
class FakeNostrClient:
def __init__(self, *args, **kwargs):
@@ -21,81 +15,51 @@ class FakeNostrClient:
return None, "abcd"
def test_handle_display_totp_codes(monkeypatch, capsys):
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)
def test_handle_display_totp_codes(monkeypatch, capsys, password_manager):
pm = password_manager
pm.nostr_client = FakeNostrClient()
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
pm.entry_manager.add_totp("Example", TEST_SEED)
entry_mgr.add_totp("Example", TEST_SEED)
monkeypatch.setattr(
"seedpass.core.totp.TotpManager.current_code_from_secret",
lambda *a, **k: "123456",
)
monkeypatch.setattr(pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30)
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30
)
# interrupt the loop after first iteration
monkeypatch.setattr(
"seedpass.core.manager.timed_input",
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
)
# interrupt the loop after first iteration
monkeypatch.setattr(
"seedpass.core.manager.timed_input",
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
)
pm.handle_display_totp_codes()
out = capsys.readouterr().out
assert "Generated 2FA Codes" in out
assert "[0] Example" in out
assert "123456" in out
pm.handle_display_totp_codes()
out = capsys.readouterr().out
assert "Imported 2FA Codes" in out
assert "[0] Example" in out
assert "123456" in out
def test_display_totp_codes_excludes_archived(monkeypatch, capsys):
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)
def test_display_totp_codes_excludes_archived(monkeypatch, capsys, password_manager):
pm = password_manager
pm.nostr_client = FakeNostrClient()
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
pm.entry_manager.add_totp("Visible", TEST_SEED)
pm.entry_manager.add_totp("Hidden", TEST_SEED)
pm.entry_manager.modify_entry(1, archived=True)
entry_mgr.add_totp("Visible", TEST_SEED)
entry_mgr.add_totp("Hidden", TEST_SEED)
entry_mgr.modify_entry(1, archived=True)
monkeypatch.setattr(
"seedpass.core.totp.TotpManager.current_code_from_secret",
lambda *a, **k: "123456",
)
monkeypatch.setattr(pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30)
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30
)
monkeypatch.setattr(
"seedpass.core.manager.timed_input",
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
)
monkeypatch.setattr(
"seedpass.core.manager.timed_input",
lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()),
)
pm.handle_display_totp_codes()
out = capsys.readouterr().out
assert "Visible" in out
assert "Hidden" not in out
pm.handle_display_totp_codes()
out = capsys.readouterr().out
assert "Visible" in out
assert "Hidden" not in out
+5 -3
View File
@@ -301,7 +301,7 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
"seedpass.core.manager.confirm_action", lambda *a, **k: True
)
monkeypatch.setattr(
"seedpass.core.manager.copy_to_clipboard", lambda *a, **k: None
"seedpass.core.manager.copy_to_clipboard", lambda *a, **k: True
)
monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b")
monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None)
@@ -346,7 +346,8 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
entry_mgr.add_totp("Example", TEST_SEED)
idx = 0
monkeypatch.setattr(
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
"seedpass.core.manager.TotpManager.current_code_from_secret",
lambda *a, **k: "123456",
)
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
@@ -383,7 +384,8 @@ def test_show_entry_details_with_enum_type(monkeypatch, capsys, entry_type):
entry_mgr.add_totp("Example", TEST_SEED)
idx = 0
monkeypatch.setattr(
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
"seedpass.core.manager.TotpManager.current_code_from_secret",
lambda *a, **k: "123456",
)
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
+4 -1
View File
@@ -45,7 +45,10 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys):
inputs = iter(["0", "n", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
monkeypatch.setattr(
"seedpass.core.manager.TotpManager.current_code_from_secret",
lambda *a, **k: "123456",
)
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
)
+16
View File
@@ -0,0 +1,16 @@
import asyncio
def test_published_events_no_fingerprint(dummy_nostr_client):
client, relay = dummy_nostr_client
asyncio.run(client.publish_snapshot(b"secret"))
fingerprint = "fp"
events = list(relay.manifests) + list(relay.chunks.values())
seen = set()
for ev in events:
if id(ev) in seen:
continue
seen.add(id(ev))
assert fingerprint not in ev.id
for tag in getattr(ev, "tags", []):
assert fingerprint not in tag
+4 -4
View File
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, dummy_nostr_client, TEST_SEED
from helpers import create_vault, TEST_SEED
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
@@ -34,8 +34,8 @@ def _init_pm(dir_path: Path, client) -> PasswordManager:
return pm
def test_manifest_state_restored(monkeypatch, tmp_path):
client, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch)
def test_manifest_state_restored(monkeypatch, tmp_path, make_dummy_nostr_client):
client, relay = make_dummy_nostr_client(tmp_path / "c1")
with TemporaryDirectory() as tmpdir:
fp_dir = Path(tmpdir)
pm1 = _init_pm(fp_dir, client)
@@ -48,7 +48,7 @@ def test_manifest_state_restored(monkeypatch, tmp_path):
assert delta_ts > 0
assert result["manifest_id"] == manifest_id
client2, _ = dummy_nostr_client.__wrapped__(tmp_path / "c2", monkeypatch)
client2, _ = make_dummy_nostr_client(tmp_path / "c2")
monkeypatch.setattr(
"seedpass.core.manager.NostrClient", lambda *a, **k: client2
)
+26
View File
@@ -78,3 +78,29 @@ def test_error_on_future_version(tmp_path: Path):
enc_mgr.save_json_data(future)
with pytest.raises(ValueError):
vault.load_index()
def test_schema_migration_persisted_once(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
legacy = {
"schema_version": 3,
"entries": {
"0": {
"label": "a",
"length": 8,
"type": "password",
"notes": "",
"custom_fields": [],
"origin": "",
}
},
}
enc_mgr.save_json_data(legacy)
data, migrated, _ = vault.load_index(return_migration_flags=True)
assert migrated is True
assert data["schema_version"] == LATEST_VERSION
assert data["entries"]["0"]["tags"] == []
data_again, migrated_again, _ = vault.load_index(return_migration_flags=True)
assert migrated_again is False
assert data_again == data
+1 -1
View File
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, dummy_nostr_client
from helpers import create_vault
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
@@ -0,0 +1,55 @@
import pytest
from pathlib import Path
from tempfile import TemporaryDirectory
from seedpass.core.manager import PasswordManager
from utils.fingerprint_manager import FingerprintManager
from utils.fingerprint import generate_fingerprint
from seedpass.core.state_manager import StateManager
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
def setup_pm(tmp_path, monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_manager = FingerprintManager(tmp_path)
pm.config_manager = type("Cfg", (), {"get_kdf_iterations": lambda self: 1})()
pm.state_manager = StateManager(tmp_path)
monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "pw")
monkeypatch.setattr("seedpass.core.manager.derive_index_key", lambda seed: b"idx")
monkeypatch.setattr(
"seedpass.core.manager.derive_key_from_password", lambda *a, **k: b"k"
)
class DummyEnc:
def __init__(self, *a, **k):
pass
def encrypt_parent_seed(self, seed):
pass
monkeypatch.setattr("seedpass.core.manager.EncryptionManager", DummyEnc)
monkeypatch.setattr("seedpass.core.manager.Vault", lambda *a, **k: object())
monkeypatch.setattr(
"seedpass.core.manager.ConfigManager", lambda **k: pm.config_manager
)
monkeypatch.setattr(pm, "initialize_bip85", lambda: None)
monkeypatch.setattr(pm, "initialize_managers", lambda: None)
monkeypatch.setattr(pm, "start_background_sync", lambda: None)
monkeypatch.setattr(pm, "store_hashed_password", lambda pw: None)
return pm
def test_generate_new_seed_creates_profile(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm = setup_pm(tmp_path, monkeypatch)
monkeypatch.setattr(pm, "generate_bip85_seed", lambda: VALID_SEED)
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_: True)
fingerprint = pm.generate_new_seed()
assert fingerprint == generate_fingerprint(VALID_SEED)
assert pm.fingerprint_manager.list_fingerprints() == [fingerprint]
sm = StateManager(tmp_path / fingerprint)
assert sm.state["nostr_account_idx"] == 1
+19
View File
@@ -0,0 +1,19 @@
from pathlib import Path
from helpers import TEST_SEED
from utils.key_derivation import derive_index_key
from seedpass.core.encryption import EncryptionManager
def test_nonce_uniqueness(tmp_path: Path) -> None:
key = derive_index_key(TEST_SEED)
manager = EncryptionManager(key, tmp_path)
plaintext = b"repeat"
nonces = set()
for _ in range(10):
payload = manager.encrypt_data(plaintext)
assert payload.startswith(b"V3|")
nonce = payload[3:15]
assert nonce not in nonces
nonces.add(nonce)
assert len(nonces) == 10
+1 -1
View File
@@ -7,7 +7,7 @@ import constants
import seedpass.core.manager as manager_module
from utils.fingerprint_manager import FingerprintManager
from seedpass.core.config_manager import ConfigManager
from tests.helpers import TEST_SEED, TEST_PASSWORD, create_vault
from helpers import TEST_SEED, TEST_PASSWORD, create_vault
def test_init_with_password(monkeypatch):
+30 -5
View File
@@ -90,7 +90,7 @@ def _setup_client(tmpdir, fake_cls):
def test_initialize_client_pool_add_relays_used(tmp_path):
client = _setup_client(tmp_path, FakeAddRelaysClient)
fc = client.client
client.connect()
asyncio.run(client.connect())
assert [[str(r) for r in relays] for relays in fc.added] == [client.relays]
assert fc.connected is True
@@ -98,7 +98,7 @@ def test_initialize_client_pool_add_relays_used(tmp_path):
def test_initialize_client_pool_add_relay_fallback(tmp_path):
client = _setup_client(tmp_path, FakeAddRelayClient)
fc = client.client
client.connect()
asyncio.run(client.connect())
assert [str(r) for r in fc.added] == client.relays
assert fc.connected is True
@@ -166,17 +166,42 @@ def test_retrieve_json_sync_backoff(tmp_path, monkeypatch):
sleeps: list[float] = []
def fake_sleep(d):
async def fake_sleep(d, *_a, **_k):
sleeps.append(d)
monkeypatch.setattr(nostr_client.time, "sleep", fake_sleep)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
async def fake_async(self):
return None
monkeypatch.setattr(NostrClient, "_retrieve_json_from_nostr", fake_async)
result = client.retrieve_json_from_nostr_sync()
result = asyncio.run(client.retrieve_json_from_nostr())
assert result is None
assert sleeps == [1, 2]
def test_client_methods_run_in_event_loop(tmp_path, monkeypatch):
client = _setup_client(tmp_path, FakeAddRelayClient)
async def fake_init(self):
self._connected = True
async def fake_send_event(_event):
return None
async def fake_retrieve(self):
return b"data"
monkeypatch.setattr(NostrClient, "_initialize_client_pool", fake_init)
monkeypatch.setattr(client.client, "send_event", fake_send_event, raising=False)
monkeypatch.setattr(NostrClient, "_retrieve_json_from_nostr", fake_retrieve)
async def run_all():
await client.connect()
await client.publish_event("e")
data = await client.retrieve_json_from_nostr()
assert data == b"data"
asyncio.run(run_all())
+1 -1
View File
@@ -3,7 +3,7 @@ import gzip
import math
import pytest
from helpers import create_vault, dummy_nostr_client, TEST_SEED
from helpers import create_vault, TEST_SEED
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
@@ -0,0 +1,38 @@
import json
import base64
import hashlib
import unicodedata
import logging
from cryptography.fernet import Fernet
from helpers import create_vault, TEST_PASSWORD
import seedpass.core.encryption as enc_module
def test_legacy_password_only_fallback(monkeypatch, tmp_path, caplog):
calls: list[int] = []
def _fast_legacy_key(password: str, iterations: int = 100_000) -> bytes:
calls.append(iterations)
normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8")
key = hashlib.pbkdf2_hmac("sha256", normalized, b"", 1, dklen=32)
return base64.urlsafe_b64encode(key)
# Speed up legacy key derivation
monkeypatch.setattr(
enc_module, "_derive_legacy_key_from_password", _fast_legacy_key
)
monkeypatch.setattr(
enc_module, "prompt_existing_password", lambda *_a, **_k: TEST_PASSWORD
)
vault, enc_mgr = create_vault(tmp_path)
data = {"schema_version": 4, "entries": {}}
legacy_key = _fast_legacy_key(TEST_PASSWORD, iterations=50_000)
encrypted = Fernet(legacy_key).encrypt(json.dumps(data).encode())
caplog.set_level(logging.WARNING)
assert enc_mgr.decrypt_and_save_index_from_nostr(encrypted)
assert vault.load_index() == data
assert any("legacy password-only" in rec.message for rec in caplog.records)
assert calls == [50_000, 50_000]
@@ -0,0 +1,83 @@
import asyncio
import base64
import hashlib
import json
from helpers import DummyEvent, DummyFilter
from nostr.backup_models import KIND_MANIFEST, KIND_SNAPSHOT_CHUNK
from nostr_sdk import Keys
def test_fetch_snapshot_legacy_key_fallback(dummy_nostr_client, monkeypatch):
client, relay = dummy_nostr_client
# Track legacy key generation
called = {"legacy": False}
class LegacyKeys:
def private_key_hex(self):
return "3" * 64
def public_key_hex(self):
return "4" * 64
def fake_generate():
called["legacy"] = True
return LegacyKeys()
monkeypatch.setattr(
client.key_manager, "generate_legacy_nostr_keys", fake_generate, raising=False
)
expected_pubkey = Keys.parse("3" * 64).public_key()
class RecordingFilter(DummyFilter):
def author(self, pk):
self.author_pk = pk
return self
monkeypatch.setattr("nostr.client.Filter", RecordingFilter)
chunk_bytes = b"chunkdata"
chunk_hash = hashlib.sha256(chunk_bytes).hexdigest()
manifest_json = json.dumps(
{
"ver": 1,
"algo": "gzip",
"chunks": [
{
"id": "seedpass-chunk-0000",
"size": len(chunk_bytes),
"hash": chunk_hash,
"event_id": None,
}
],
}
)
manifest_event = DummyEvent(KIND_MANIFEST, manifest_json, tags=["legacy"])
chunk_event = DummyEvent(
KIND_SNAPSHOT_CHUNK,
base64.b64encode(chunk_bytes).decode("utf-8"),
tags=["seedpass-chunk-0000"],
)
call = {"count": 0, "authors": []}
async def fake_fetch_events(f, _timeout):
call["count"] += 1
call["authors"].append(getattr(f, "author_pk", None))
if call["count"] == 1:
return type("R", (), {"to_vec": lambda self: []})()
elif call["count"] == 2:
return type("R", (), {"to_vec": lambda self: [manifest_event]})()
else:
return type("R", (), {"to_vec": lambda self: [chunk_event]})()
monkeypatch.setattr(relay, "fetch_events", fake_fetch_events)
result = asyncio.run(client.fetch_latest_snapshot())
assert called["legacy"]
assert result is not None
manifest, chunks = result
assert b"".join(chunks) == chunk_bytes
assert call["authors"][-1].to_hex() == expected_pubkey.to_hex()
+3 -3
View File
@@ -1,7 +1,7 @@
from pathlib import Path
import main
from helpers import create_vault, dummy_nostr_client, TEST_SEED, TEST_PASSWORD
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
@@ -28,8 +28,8 @@ def _init_pm(dir_path: Path, client) -> PasswordManager:
return pm
def test_restore_flow_from_snapshot(monkeypatch, tmp_path):
client, relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch)
def test_restore_flow_from_snapshot(monkeypatch, tmp_path, make_dummy_nostr_client):
client, relay = make_dummy_nostr_client(tmp_path / "srv")
dir_a = tmp_path / "A"
dir_b = tmp_path / "B"
-1
View File
@@ -4,7 +4,6 @@ import threading
import time
from websocket import create_connection
import asyncio
import websockets
from nostr.key_manager import KeyManager
from nostr_sdk import nostr_sdk as sdk
@@ -0,0 +1,14 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from seedpass.core.config_manager import ConfigManager
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
def test_offline_mode_default_enabled():
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)
config = cfg_mgr.load_config(require_pin=False)
assert config["offline_mode"] is True
@@ -0,0 +1,32 @@
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from main import handle_toggle_offline_mode
from seedpass.core.manager import PasswordManager
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from utils.fingerprint import generate_fingerprint
def test_toggle_offline_mode_after_profile_creation(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
pm = PasswordManager.__new__(PasswordManager)
pm.vault = vault
pm.encryption_manager = enc_mgr
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = generate_fingerprint(TEST_SEED)
pm.offline_mode = False
pm.config_manager = None
inputs = iter(["y"])
monkeypatch.setattr("builtins.input", lambda *a: next(inputs))
handle_toggle_offline_mode(pm)
assert pm.offline_mode is True
assert pm.config_manager is not None
cfg = pm.config_manager.load_config(require_pin=False)
assert cfg["offline_mode"] is True
+1
View File
@@ -35,6 +35,7 @@ def test_change_password_triggers_nostr_backup(monkeypatch):
pm.parent_seed = TEST_SEED
pm.store_hashed_password = lambda pw: None
pm.verify_password = lambda pw: True
pm.nostr_account_idx = 0
with patch("seedpass.core.manager.NostrClient") as MockClient:
mock_instance = MockClient.return_value
+2 -2
View File
@@ -13,8 +13,8 @@ class DummyEnc:
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator(policy=None):
+2 -2
View File
@@ -8,8 +8,8 @@ class DummyEnc:
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator():
@@ -14,8 +14,8 @@ class DummyEnc:
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator():
+2 -2
View File
@@ -15,8 +15,8 @@ class DummyEnc:
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator():
Loaded 100 of 462 files, more files were not shown because too many files have changed in this diff. Show more