233 lines
7.8 KiB
Python
233 lines
7.8 KiB
Python
"""Tests for KeyringKeyStore and InMemoryKeyStore."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from noteflow.infrastructure.security import keystore
|
|
|
|
|
|
def _install_fake_keyring(monkeypatch: pytest.MonkeyPatch) -> dict[tuple[str, str], str]:
|
|
"""Install a fake keyring backend backed by a dictionary."""
|
|
storage: dict[tuple[str, str], str] = {}
|
|
|
|
class DummyErrors:
|
|
class KeyringError(Exception): ...
|
|
|
|
class PasswordDeleteError(KeyringError): ...
|
|
|
|
def get_password(service: str, key: str) -> str | None:
|
|
return storage.get((service, key))
|
|
|
|
def set_password(service: str, key: str, value: str) -> None:
|
|
storage[(service, key)] = value
|
|
|
|
def delete_password(service: str, key: str) -> None:
|
|
storage.pop((service, key), None)
|
|
|
|
monkeypatch.setattr(
|
|
keystore,
|
|
"keyring",
|
|
types.SimpleNamespace(
|
|
get_password=get_password,
|
|
set_password=set_password,
|
|
delete_password=delete_password,
|
|
errors=DummyErrors,
|
|
),
|
|
)
|
|
return storage
|
|
|
|
|
|
def test_get_or_create_master_key_creates_and_reuses(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Master key should be created once and then reused."""
|
|
monkeypatch.delenv(keystore.ENV_VAR_NAME, raising=False)
|
|
storage = _install_fake_keyring(monkeypatch)
|
|
ks = keystore.KeyringKeyStore(service_name="svc", key_name="key")
|
|
|
|
first = ks.get_or_create_master_key()
|
|
second = ks.get_or_create_master_key()
|
|
|
|
assert len(first) == keystore.KEY_SIZE, "key should be KEY_SIZE bytes"
|
|
assert first == second, "second call should return same key"
|
|
assert ("svc", "key") in storage, "key should be stored in keyring"
|
|
|
|
|
|
def test_get_or_create_master_key_falls_back_to_file(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Keyring errors should fall back to file-based key storage."""
|
|
monkeypatch.delenv(keystore.ENV_VAR_NAME, raising=False)
|
|
|
|
class DummyErrors:
|
|
class KeyringError(Exception): ...
|
|
|
|
def raise_error(*_: object, **__: object) -> None:
|
|
raise DummyErrors.KeyringError("unavailable")
|
|
|
|
monkeypatch.setattr(
|
|
keystore,
|
|
"keyring",
|
|
types.SimpleNamespace(
|
|
get_password=raise_error,
|
|
set_password=raise_error,
|
|
errors=DummyErrors,
|
|
delete_password=raise_error,
|
|
),
|
|
)
|
|
# Use temp path for file fallback
|
|
key_file = tmp_path / ".master_key"
|
|
monkeypatch.setattr(keystore, "DEFAULT_KEY_FILE", key_file)
|
|
|
|
ks = keystore.KeyringKeyStore()
|
|
key = ks.get_or_create_master_key()
|
|
|
|
assert len(key) == keystore.KEY_SIZE, "fallback key should be KEY_SIZE bytes"
|
|
assert key_file.exists(), "key should be persisted to file on fallback"
|
|
|
|
|
|
def test_delete_master_key_handles_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""delete_master_key should swallow missing-key errors."""
|
|
storage = _install_fake_keyring(monkeypatch)
|
|
|
|
class DummyErrors:
|
|
class KeyringError(Exception): ...
|
|
|
|
class PasswordDeleteError(KeyringError): ...
|
|
|
|
# Reinstall with errors that raise on delete to exercise branch
|
|
def delete_password(*_: object, **__: object) -> None:
|
|
raise DummyErrors.PasswordDeleteError("not found")
|
|
|
|
def get_password(service: str, key: str) -> str | None:
|
|
return storage.get((service, key))
|
|
|
|
def set_password(service: str, key: str, value: str) -> str | None:
|
|
return storage.setdefault((service, key), value)
|
|
|
|
monkeypatch.setattr(
|
|
keystore,
|
|
"keyring",
|
|
types.SimpleNamespace(
|
|
get_password=get_password,
|
|
set_password=set_password,
|
|
delete_password=delete_password,
|
|
errors=DummyErrors,
|
|
),
|
|
)
|
|
|
|
ks = keystore.KeyringKeyStore()
|
|
# Should not raise even when delete_password errors
|
|
ks.delete_master_key()
|
|
|
|
|
|
def test_has_master_key_false_on_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""has_master_key should return False when keyring raises."""
|
|
|
|
class DummyErrors:
|
|
class KeyringError(Exception): ...
|
|
|
|
def raise_error(*_: object, **__: object) -> None:
|
|
raise DummyErrors.KeyringError("oops")
|
|
|
|
def noop_delete(service: str, key: str) -> None:
|
|
pass
|
|
|
|
def noop_set(service: str, key: str, value: str) -> None:
|
|
pass
|
|
|
|
monkeypatch.setattr(
|
|
keystore,
|
|
"keyring",
|
|
types.SimpleNamespace(
|
|
get_password=raise_error,
|
|
errors=DummyErrors,
|
|
delete_password=noop_delete,
|
|
set_password=noop_set,
|
|
),
|
|
)
|
|
|
|
ks = keystore.KeyringKeyStore()
|
|
assert ks.has_master_key() is False, "should return False when keyring raises"
|
|
|
|
|
|
class TestFileKeyStore:
|
|
"""Tests for FileKeyStore fallback implementation."""
|
|
|
|
def test_creates_and_reuses_key(self, tmp_path: Path) -> None:
|
|
"""File key store should create key once and reuse it."""
|
|
key_file = tmp_path / ".master_key"
|
|
fks = keystore.FileKeyStore(key_file)
|
|
|
|
first = fks.get_or_create_master_key()
|
|
second = fks.get_or_create_master_key()
|
|
|
|
assert len(first) == keystore.KEY_SIZE, "key should be KEY_SIZE bytes"
|
|
assert first == second, "second call should return same key"
|
|
assert key_file.exists(), "key should be persisted to file"
|
|
|
|
def test_creates_parent_directories(self, tmp_path: Path) -> None:
|
|
"""File key store should create parent directories."""
|
|
key_file = tmp_path / "nested" / "dir" / ".master_key"
|
|
fks = keystore.FileKeyStore(key_file)
|
|
|
|
fks.get_or_create_master_key()
|
|
|
|
assert key_file.exists(), "parent directories should be created"
|
|
|
|
def test_has_master_key_true_when_exists(self, tmp_path: Path) -> None:
|
|
"""has_master_key should return True when file exists."""
|
|
key_file = tmp_path / ".master_key"
|
|
fks = keystore.FileKeyStore(key_file)
|
|
fks.get_or_create_master_key()
|
|
|
|
assert fks.has_master_key() is True, "should return True when key file exists"
|
|
|
|
def test_has_master_key_false_when_missing(self, tmp_path: Path) -> None:
|
|
"""has_master_key should return False when file is missing."""
|
|
key_file = tmp_path / ".master_key"
|
|
fks = keystore.FileKeyStore(key_file)
|
|
|
|
assert fks.has_master_key() is False, "should return False when key file missing"
|
|
|
|
def test_delete_master_key_removes_file(self, tmp_path: Path) -> None:
|
|
"""delete_master_key should remove the key file."""
|
|
key_file = tmp_path / ".master_key"
|
|
fks = keystore.FileKeyStore(key_file)
|
|
fks.get_or_create_master_key()
|
|
|
|
fks.delete_master_key()
|
|
|
|
assert not key_file.exists(), "key file should be deleted"
|
|
|
|
def test_delete_master_key_safe_when_missing(self, tmp_path: Path) -> None:
|
|
"""delete_master_key should not raise when file is missing."""
|
|
key_file = tmp_path / ".master_key"
|
|
fks = keystore.FileKeyStore(key_file)
|
|
|
|
fks.delete_master_key() # Should not raise
|
|
|
|
def test_invalid_base64_raises_runtime_error(self, tmp_path: Path) -> None:
|
|
"""Invalid base64 in key file should raise RuntimeError."""
|
|
key_file = tmp_path / ".master_key"
|
|
key_file.write_text("not-valid-base64!!!")
|
|
fks = keystore.FileKeyStore(key_file)
|
|
|
|
with pytest.raises(RuntimeError, match="invalid base64"):
|
|
fks.get_or_create_master_key()
|
|
|
|
def test_wrong_size_raises_runtime_error(self, tmp_path: Path) -> None:
|
|
"""Wrong key size in file should raise RuntimeError."""
|
|
import base64
|
|
|
|
key_file = tmp_path / ".master_key"
|
|
# Write a key that's too short (16 bytes instead of 32)
|
|
key_file.write_text(base64.b64encode(b"short_key").decode())
|
|
fks = keystore.FileKeyStore(key_file)
|
|
|
|
with pytest.raises(RuntimeError, match="wrong key size"):
|
|
fks.get_or_create_master_key()
|