mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-03 11:22:03 +00:00
Merge branch 'beta' into main (safe conflict resolution)
This commit is contained in:
commit
37d0dffdc3
462 files changed
+68862
-6529
No files matched your search
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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'"
|
||||
@@ -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 == {}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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."
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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|")
|
||||
@@ -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|")
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
Reference in new issue
Block a user