Files
noteflow/tests/infrastructure/security/test_keystore.py
Travis Vasceannie 301482c410
Some checks failed
CI / test-python (push) Successful in 8m41s
CI / test-typescript (push) Failing after 6m2s
CI / test-rust (push) Failing after 4m28s
Refactor: Improve CI workflow robustness and test environment variable management, and enable parallel quality test execution.
2026-01-26 02:04:38 +00:00

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