Files
ss-tools/backend/tests/core/test_encryption.py
root 632b730fff chore: migrate GRACE-Poly anchors to hierarchical dotted naming
Systematic rename of all semantic anchors (#region, [DEF], @RELATION)
across 1400+ files — backend Python, frontend Svelte/TS, specs, docs:
- Flat anchors become Namespace.Module.Entity
- @RELATION references updated to match new anchor paths
- Zero business logic changes
2026-07-22 11:48:15 +03:00

218 lines
10 KiB
Python

# #region Test.Encryption [C:3] [TYPE Module] [SEMANTICS test,encryption,fernet,key,crypto]
# @BRIEF Verify EncryptionManager encrypt/decrypt and ensure_encryption_key resolution contracts.
# @RELATION BINDS_TO -> [Core.Encryption.EncryptionCore]
# @RELATION BINDS_TO -> [Core.EncryptionKey.EncryptionKeyModule]
# @TEST_EDGE: missing_key -> RuntimeError when ENCRYPTION_KEY absent
# @TEST_EDGE: invalid_key -> RuntimeError when ENCRYPTION_KEY is not valid Fernet format
# @TEST_EDGE: external_fail -> decrypt of garbage data raises Exception
# @TEST_INVARIANT: symmetric_encryption -> VERIFIED_BY: encrypt_decrypt_cycle, empty_string_encryption
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
# Hardcoded valid Fernet key — generated once, frozen as fixture.
# 32 url-safe base64 bytes. Never recompute at test time (anti-tautology).
_VALID_FERNET_KEY = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0="
# ── _require_fernet_key ─────────────────────────────────────────────────────
class TestRequireFernetKey:
"""_require_fernet_key — loads and validates Fernet key from environment."""
# #region Test.Encryption.TestRequireKeyValid [C:2] [TYPE Function]
# @BRIEF Valid ENCRYPTION_KEY returns key bytes without error.
def test_require_key_valid(self, monkeypatch):
from src.core.encryption import _require_fernet_key
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
result = _require_fernet_key()
assert result == _VALID_FERNET_KEY.encode()
# #endregion Test.Encryption.TestRequireKeyValid
# #region Test.Encryption.TestRequireKeyMissing [C:2] [TYPE Function]
# @BRIEF Missing ENCRYPTION_KEY raises RuntimeError with startup guidance.
def test_require_key_missing(self, monkeypatch):
from src.core.encryption import _require_fernet_key
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
_require_fernet_key()
# #endregion Test.Encryption.TestRequireKeyMissing
# #region Test.Encryption.TestRequireKeyInvalid [C:2] [TYPE Function]
# @BRIEF Malformed ENCRYPTION_KEY raises RuntimeError about valid Fernet key.
def test_require_key_invalid(self, monkeypatch):
from src.core.encryption import _require_fernet_key
monkeypatch.setenv("ENCRYPTION_KEY", "not-a-valid-fernet-key")
with pytest.raises(RuntimeError, match="valid Fernet key"):
_require_fernet_key()
# #endregion Test.Encryption.TestRequireKeyInvalid
# #region Test.Encryption.TestRequireKeyWhitespaceOnly [C:2] [TYPE Function]
# @BRIEF Whitespace-only ENCRYPTION_KEY is treated as missing.
def test_require_key_whitespace_only(self, monkeypatch):
from src.core.encryption import _require_fernet_key
monkeypatch.setenv("ENCRYPTION_KEY", " ")
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
_require_fernet_key()
# #endregion Test.Encryption.TestRequireKeyWhitespaceOnly
# ── EncryptionManager ───────────────────────────────────────────────────────
class TestEncryptionManager:
"""EncryptionManager — encrypt/decrypt operations with validated Fernet key."""
# #region Test.Encryption.TestEncryptDecryptCycle [C:2] [TYPE Function]
# @BRIEF Encrypt then decrypt returns original plaintext (symmetric invariant).
def test_encrypt_decrypt_cycle(self, monkeypatch):
from src.core.encryption import EncryptionManager
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
mgr = EncryptionManager()
# Hardcoded fixture — plaintext declared before assertion, not computed.
plaintext = "my_secret_key"
encrypted = mgr.encrypt(plaintext)
assert encrypted != plaintext
assert mgr.decrypt(encrypted) == plaintext
# #endregion Test.Encryption.TestEncryptDecryptCycle
# #region Test.Encryption.TestEmptyStringEncryption [C:2] [TYPE Function]
# @BRIEF Empty string survives encrypt/decrypt cycle.
def test_empty_string_encryption(self, monkeypatch):
from src.core.encryption import EncryptionManager
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
mgr = EncryptionManager()
encrypted = mgr.encrypt("")
assert mgr.decrypt(encrypted) == ""
# #endregion Test.Encryption.TestEmptyStringEncryption
# #region Test.Encryption.TestDecryptGarbageRaises [C:2] [TYPE Function]
# @BRIEF Decrypting non-Fernet data raises an exception (external_fail edge).
def test_decrypt_garbage_raises(self, monkeypatch):
from src.core.encryption import EncryptionManager
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
mgr = EncryptionManager()
with pytest.raises(Exception):
mgr.decrypt("not-encrypted-data")
# #endregion Test.Encryption.TestDecryptGarbageRaises
# #region Test.Encryption.TestInitMissingKeyRaises [C:2] [TYPE Function]
# @BRIEF EncryptionManager() without ENCRYPTION_KEY raises RuntimeError at init.
def test_init_missing_key_raises(self, monkeypatch):
from src.core.encryption import EncryptionManager
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
EncryptionManager()
# #endregion Test.Encryption.TestInitMissingKeyRaises
# ── ensure_encryption_key ───────────────────────────────────────────────────
class TestEnsureEncryptionKey:
"""ensure_encryption_key — resolve key from env or .env file, crash-early if absent."""
# #region Test.Encryption.TestEnsureKeyFromEnv [C:2] [TYPE Function]
# @BRIEF Returns key from process environment when ENCRYPTION_KEY is set.
def test_ensure_key_from_env(self, monkeypatch):
from src.core.encryption_key import ensure_encryption_key
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
result = ensure_encryption_key()
assert result == _VALID_FERNET_KEY
# #endregion Test.Encryption.TestEnsureKeyFromEnv
# #region Test.Encryption.TestEnsureKeyFromDotenvFile [C:2] [TYPE Function]
# @BRIEF Loads key from .env file when not in process environment; sets os.environ.
def test_ensure_key_from_dotenv_file(self, monkeypatch, tmp_path):
from src.core.encryption_key import ensure_encryption_key
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
env_file = tmp_path / ".env"
env_file.write_text(f"ENCRYPTION_KEY={_VALID_FERNET_KEY}\nOTHER=value\n")
result = ensure_encryption_key(env_file)
assert result == _VALID_FERNET_KEY
# Side-effect: key must be propagated to process environment.
import os
assert os.environ.get("ENCRYPTION_KEY") == _VALID_FERNET_KEY
# #endregion Test.Encryption.TestEnsureKeyFromDotenvFile
# #region Test.Encryption.TestEnsureKeyNoFileRaises [C:2] [TYPE Function]
# @BRIEF Crash-early when no .env file exists and env var is unset.
def test_ensure_key_no_file_raises(self, monkeypatch):
from src.core.encryption_key import ensure_encryption_key
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"):
ensure_encryption_key(Path("/nonexistent/.env"))
# #endregion Test.Encryption.TestEnsureKeyNoFileRaises
# #region Test.Encryption.TestEnsureKeyFileWithoutKeyRaises [C:2] [TYPE Function]
# @BRIEF Crash-early when .env file exists but contains no ENCRYPTION_KEY line.
def test_ensure_key_file_without_key_raises(self, monkeypatch, tmp_path):
from src.core.encryption_key import ensure_encryption_key
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
env_file = tmp_path / ".env"
env_file.write_text("OTHER=value\nUNRELATED=stuff\n")
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"):
ensure_encryption_key(env_file)
# #endregion Test.Encryption.TestEnsureKeyFileWithoutKeyRaises
# ── get_encryption_manager singleton ────────────────────────────────────────
class TestGetEncryptionManager:
"""get_encryption_manager() — process-wide singleton."""
# #region Test.Encryption.TestSingletonReturnsSameInstance [C:2] [TYPE Function] [SEMANTICS test,singleton]
# @BRIEF Two calls to get_encryption_manager return the identical object.
def test_singleton_returns_same_instance(self, monkeypatch):
from src.core.encryption import get_encryption_manager
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
# Reset module-level singleton
import src.core.encryption as enc_mod
enc_mod._encryption_manager = None
mgr1 = get_encryption_manager()
mgr2 = get_encryption_manager()
assert mgr1 is mgr2
# #endregion Test.Encryption.TestSingletonReturnsSameInstance
# #region Test.Encryption.TestSingletonAfterException [C:2] [TYPE Function] [SEMANTICS test,singleton,recovery]
# @BRIEF If first creation fails (no ENCRYPTION_KEY), singleton resets
# properly — subsequent call with valid key succeeds and is cached.
def test_singleton_after_exception(self, monkeypatch):
from src.core.encryption import get_encryption_manager
# Ensure no key — first call raises
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
import src.core.encryption as enc_mod
enc_mod._encryption_manager = None
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
get_encryption_manager()
# Singleton remains None after exception
assert enc_mod._encryption_manager is None
# Now set valid key — second call succeeds
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
mgr = get_encryption_manager()
assert mgr is not None
# And subsequent call returns same instance
assert get_encryption_manager() is mgr
# #endregion Test.Encryption.TestSingletonAfterException
# #endregion Test.Encryption