Files
roboco/tests/unit/utils/test_crypto.py
T
Renn F 02f43f5dfe fix(tests): make crypto tests hermetic with a self-supplied Fernet key
test_crypto's round-trip tests called the real encrypt/decrypt path, which needs settings.encryption_key configured — so they silently depended on ROBOCO_ENCRYPTION_KEY being present in the environment. In agent gate containers it is not, so four tests failed there and the agent mis-read it as a code regression. An autouse fixture now monkeypatches a valid generated Fernet key, so the tests pass without any ambient secret and agents never need the production key injected to gate.
2026-06-22 13:04:26 +02:00

157 lines
5.2 KiB
Python

"""utils.crypto coverage — Fernet encrypt/decrypt round-trip + error paths."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from cryptography.fernet import Fernet
from roboco.config import settings
from roboco.utils.crypto import (
EncryptionError,
_get_fernet,
decrypt_token,
encrypt_token,
is_encryption_configured,
)
@pytest.fixture(autouse=True)
def _configured_encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
"""Supply a valid Fernet key for the tests that exercise real crypto.
The round-trip tests need a configured key; without this they depend on
``ROBOCO_ENCRYPTION_KEY`` being set in the environment — it is not in agent
gate containers, which is exactly why they failed there. The patch-based
tests below replace ``settings`` wholesale inside their ``with`` blocks, so
this autouse default never interferes with them.
"""
monkeypatch.setattr(settings, "encryption_key", Fernet.generate_key().decode())
def test_encrypt_decrypt_round_trip() -> None:
plaintext = "sk-secret-test-token-12345"
encrypted = encrypt_token(plaintext)
assert encrypted != plaintext
decrypted = decrypt_token(encrypted)
assert decrypted == plaintext
def test_encrypt_empty_string_raises() -> None:
with pytest.raises(EncryptionError, match="empty"):
encrypt_token("")
def test_decrypt_empty_string_raises() -> None:
with pytest.raises(EncryptionError, match="empty"):
decrypt_token("")
def test_decrypt_invalid_token_raises() -> None:
with pytest.raises(EncryptionError):
decrypt_token("not-a-valid-token-at-all")
def test_is_encryption_configured() -> None:
"""Encryption must be configured for tests to run."""
assert is_encryption_configured() is True
def test_encrypt_long_string() -> None:
plaintext = "a" * 1000
encrypted = encrypt_token(plaintext)
decrypted = decrypt_token(encrypted)
assert decrypted == plaintext
def test_encrypt_unicode() -> None:
plaintext = "héllo wörld 中文 🚀"
encrypted = encrypt_token(plaintext)
decrypted = decrypt_token(encrypted)
assert decrypted == plaintext
# ---------------------------------------------------------------------------
# Fail paths — cover the guards in _get_fernet, encrypt_token, decrypt_token.
# ---------------------------------------------------------------------------
def test_get_fernet_raises_when_key_unset() -> None:
"""_get_fernet must fail-closed when settings.encryption_key is empty."""
with (
patch("roboco.utils.crypto.settings") as mock_settings,
pytest.raises(EncryptionError, match="ROBOCO_ENCRYPTION_KEY"),
):
mock_settings.encryption_key = ""
_get_fernet()
def test_get_fernet_raises_on_invalid_key_format() -> None:
"""Bad key format → InvalidKey is wrapped in EncryptionError."""
with (
patch("roboco.utils.crypto.settings") as mock_settings,
pytest.raises(EncryptionError, match="Invalid encryption key format"),
):
mock_settings.encryption_key = "not-a-valid-fernet-key"
_get_fernet()
class _FailingFernet:
"""Fernet stand-in whose encrypt/decrypt always raise RuntimeError."""
def encrypt(self, _data: bytes) -> bytes:
raise RuntimeError("boom")
def decrypt(self, _data: bytes) -> bytes:
raise RuntimeError("boom")
def test_encrypt_token_wraps_unexpected_error() -> None:
"""Mock Fernet.encrypt to raise — encrypt_token must wrap as EncryptionError."""
with (
patch("roboco.utils.crypto._get_fernet", return_value=_FailingFernet()),
pytest.raises(EncryptionError, match="Failed to encrypt"),
):
encrypt_token("anything")
def test_decrypt_token_wraps_unexpected_error() -> None:
"""Non-InvalidToken errors get wrapped as EncryptionError."""
with (
patch("roboco.utils.crypto._get_fernet", return_value=_FailingFernet()),
pytest.raises(EncryptionError, match="Failed to decrypt"),
):
decrypt_token("any-encrypted-value")
def test_encrypt_token_reraises_encryption_error_unchanged() -> None:
"""EncryptionError from _get_fernet bubbles up without re-wrapping."""
err = EncryptionError("inner")
with (
patch("roboco.utils.crypto._get_fernet", side_effect=err),
pytest.raises(EncryptionError, match="inner"),
):
encrypt_token("anything")
def test_decrypt_token_reraises_encryption_error_unchanged() -> None:
"""EncryptionError from _get_fernet bubbles up unchanged from decrypt."""
err = EncryptionError("inner")
with (
patch("roboco.utils.crypto._get_fernet", side_effect=err),
pytest.raises(EncryptionError, match="inner"),
):
decrypt_token("any-encrypted-value")
def test_is_encryption_configured_returns_false_on_empty_key() -> None:
with patch("roboco.utils.crypto.settings") as mock_settings:
mock_settings.encryption_key = ""
assert is_encryption_configured() is False
def test_is_encryption_configured_returns_false_on_bad_key() -> None:
with patch("roboco.utils.crypto.settings") as mock_settings:
mock_settings.encryption_key = "not-a-valid-fernet-key"
assert is_encryption_configured() is False