Skip to content

Commit ae05f51

Browse files
committed
chore: add encryption tests
1 parent 3f1dd00 commit ae05f51

4 files changed

Lines changed: 149 additions & 79 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ readme = "README.md"
1010
requires-python = ">=3.12"
1111
keywords = [ "ai", "chat", "agent", "llm", "ollama", "openai",]
1212
classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12",]
13-
dependencies = [ "langchain~=0.3.25", "langchain_core~=0.3.58", "langchain_community~=0.3.24", "langchain-huggingface~=0.2.0", "sentence-transformers", "faiss-cpu", "PyYAML", "requests~=2.32.3", "hf_xet", "openai>=1.0.0", "dotenv~=0.9.9", "tiktoken~=0.9.0", "fastapi~=0.115.12", "uvicorn~=0.34.2", "pydantic~=2.11.4", "python-dotenv~=1.1.0", "openai-whisper", "coqui-tts", "sounddevice~=0.5.1", "scipy", "numpy~=1.26.4", "soundfile~=0.13.1", "python-multipart", "bs4~=0.0.2", "beautifulsoup4~=4.13.4",]
13+
dependencies = [ "langchain~=0.3.25", "langchain_core~=0.3.58", "langchain_community~=0.3.24", "langchain-huggingface~=0.2.0", "sentence-transformers", "faiss-cpu", "PyYAML", "requests~=2.32.3", "hf_xet", "openai>=1.0.0", "dotenv~=0.9.9", "tiktoken~=0.9.0", "fastapi~=0.115.12", "uvicorn~=0.34.2", "pydantic~=2.11.4", "python-dotenv~=1.1.0", "openai-whisper", "coqui-tts", "sounddevice~=0.5.1", "scipy", "numpy~=1.26.4", "soundfile~=0.13.1", "python-multipart", "bs4~=0.0.2", "beautifulsoup4~=4.13.4", "cryptography~=45.0.3", ]
1414
[[project.authors]]
1515
name = "Attila Reterics"
1616
email = "attila@reterics.com"

requirements-ci.txt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
1-
langchain~=0.3.25
21
langchain_core~=0.3.58
32
langchain_community~=0.3.24
43
sentence-transformers
54
PyYAML
65
requests~=2.32.3
7-
hf_xet
8-
openai>=1.0.0
96
dotenv~=0.9.9
107
tiktoken~=0.9.0
118
fastapi~=0.115.12
129
uvicorn~=0.34.2
13-
requests
1410
pydantic~=2.11.4
1511
python-dotenv~=1.1.0
1612
pytest~=8.3.5
1713
beautifulsoup4~=4.13.4
1814
python-multipart
1915
pytest-cov
2016
bson~=0.5.10
17+
cryptography~=45.0.3

tests/unit/test_api_server.py

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -3,80 +3,6 @@
33
from fastapi.testclient import TestClient
44

55

6-
if 'cryptography' not in sys.modules:
7-
crypto = types.ModuleType('cryptography')
8-
sys.modules['cryptography'] = crypto
9-
10-
hazmat = types.ModuleType('cryptography.hazmat')
11-
sys.modules['cryptography.hazmat'] = hazmat
12-
13-
primitives = types.ModuleType('cryptography.hazmat.primitives')
14-
sys.modules['cryptography.hazmat.primitives'] = primitives
15-
16-
# hashes stub
17-
hashes = types.ModuleType('cryptography.hazmat.primitives.hashes')
18-
sys.modules['cryptography.hazmat.primitives.hashes'] = hashes
19-
class SHA256: ...
20-
hashes.SHA256 = SHA256
21-
22-
# kdf.hkdf stub
23-
kdf = types.ModuleType('cryptography.hazmat.primitives.kdf')
24-
sys.modules['cryptography.hazmat.primitives.kdf'] = kdf
25-
hkdf = types.ModuleType('cryptography.hazmat.primitives.kdf.hkdf')
26-
sys.modules['cryptography.hazmat.primitives.kdf.hkdf'] = hkdf
27-
class HKDF:
28-
def __init__(self, algorithm=None, length=32, salt=None, info=None):
29-
self.length = length
30-
def derive(self, shared_secret: bytes) -> bytes:
31-
# return deterministic 32 bytes for tests
32-
return (b'\x00' * self.length)
33-
hkdf.HKDF = HKDF
34-
35-
# ciphers.aead AESGCM stub
36-
ciphers = types.ModuleType('cryptography.hazmat.primitives.ciphers')
37-
sys.modules['cryptography.hazmat.primitives.ciphers'] = ciphers
38-
aead = types.ModuleType('cryptography.hazmat.primitives.ciphers.aead')
39-
sys.modules['cryptography.hazmat.primitives.ciphers.aead'] = aead
40-
class AESGCM:
41-
def __init__(self, key: bytes):
42-
self.key = key
43-
def encrypt(self, iv: bytes, data: bytes, aad: bytes) -> bytes:
44-
# return placeholder bytes; tests don't assert ciphertext
45-
return b'ciphertext'
46-
aead.AESGCM = AESGCM
47-
48-
# serialization stubs
49-
serialization = types.ModuleType('cryptography.hazmat.primitives.serialization')
50-
sys.modules['cryptography.hazmat.primitives.serialization'] = serialization
51-
class Encoding:
52-
Raw = object()
53-
class PublicFormat:
54-
Raw = object()
55-
serialization.Encoding = Encoding
56-
serialization.PublicFormat = PublicFormat
57-
58-
# asymmetric.x25519 stubs
59-
asymmetric = types.ModuleType('cryptography.hazmat.primitives.asymmetric')
60-
sys.modules['cryptography.hazmat.primitives.asymmetric'] = asymmetric
61-
x25519 = types.ModuleType('cryptography.hazmat.primitives.asymmetric.x25519')
62-
sys.modules['cryptography.hazmat.primitives.asymmetric.x25519'] = x25519
63-
class X25519PrivateKey:
64-
@staticmethod
65-
def generate():
66-
return X25519PrivateKey()
67-
def public_key(self):
68-
return self
69-
def public_bytes(self, *args, **kwargs) -> bytes:
70-
return b'\x00' * 32
71-
def exchange(self, peer_public) -> bytes:
72-
return b'\x01' * 32
73-
class X25519PublicKey:
74-
@staticmethod
75-
def from_public_bytes(data: bytes):
76-
return X25519PublicKey()
77-
x25519.X25519PrivateKey = X25519PrivateKey
78-
x25519.X25519PublicKey = X25519PublicKey
79-
806
# Ensure optional heavy/third-party modules won't break import
817
# Mock AI libraries if not installed
828
sys.modules['google'] = types.ModuleType('google')

tests/unit/test_encryption.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import os
2+
from pathlib import Path
3+
import base64
4+
import json
5+
import shutil
6+
import tempfile
7+
import pytest
8+
9+
from engine.security.encryption import (
10+
EncryptionManager,
11+
encrypt_sensitive_data,
12+
decrypt_sensitive_data,
13+
)
14+
15+
16+
@pytest.fixture()
17+
def temp_dir(tmp_path: Path):
18+
# Use a temp working dir to avoid polluting repo
19+
cwd = os.getcwd()
20+
os.chdir(tmp_path)
21+
try:
22+
yield tmp_path
23+
finally:
24+
os.chdir(cwd)
25+
26+
27+
def test_encrypt_decrypt_roundtrip_string(temp_dir: Path):
28+
mgr = EncryptionManager()
29+
plaintext = "hello secret"
30+
token = mgr.encrypt(plaintext)
31+
assert isinstance(token, (bytes, bytearray))
32+
out = mgr.decrypt(token)
33+
assert out == plaintext
34+
35+
36+
def test_encrypt_decrypt_roundtrip_bytes(temp_dir: Path):
37+
mgr = EncryptionManager()
38+
data = b"\x00\x01binary"
39+
token = mgr.encrypt(data)
40+
out = mgr.decrypt(token)
41+
# decrypt returns str for non-JSON, so compare bytes->str decode
42+
assert out == data.decode()
43+
44+
45+
def test_encrypt_decrypt_roundtrip_dict(temp_dir: Path):
46+
mgr = EncryptionManager()
47+
payload = {"a": 1, "b": "two"}
48+
token = mgr.encrypt(payload)
49+
out = mgr.decrypt(token)
50+
assert isinstance(out, dict)
51+
assert out == payload
52+
53+
54+
def test_encrypt_decrypt_file_flow(temp_dir: Path):
55+
mgr = EncryptionManager()
56+
src = Path("sample.txt")
57+
src.write_text("file content", encoding="utf-8")
58+
59+
enc_path = mgr.encrypt_file(src)
60+
assert enc_path.exists()
61+
assert enc_path.suffix == ".enc"
62+
63+
dec_path = mgr.decrypt_file(enc_path)
64+
assert dec_path.exists()
65+
assert dec_path.read_text(encoding="utf-8") == "file content"
66+
67+
68+
def test_rotate_keys_and_multi_decrypt(temp_dir: Path):
69+
mgr = EncryptionManager()
70+
token_old = mgr.encrypt("keep me")
71+
72+
# rotate keys to ensure a new primary is added; keep both
73+
mgr.rotate_keys(max_keys=2)
74+
75+
# Should still decrypt using MultiFernet
76+
out = mgr.decrypt(token_old)
77+
assert out == "keep me"
78+
79+
80+
def test_reencrypt_file_and_directory(temp_dir: Path):
81+
mgr = EncryptionManager()
82+
directory = Path("vault")
83+
directory.mkdir()
84+
85+
# Prepare two files
86+
for i in range(2):
87+
p = directory / f"f{i}.txt"
88+
p.write_text(f"data {i}", encoding="utf-8")
89+
mgr.encrypt_file(p)
90+
# remove plaintext
91+
p.unlink()
92+
93+
# Now rotate keys and reencrypt directory
94+
mgr.rotate_keys(max_keys=3)
95+
success, total = mgr.reencrypt_directory(directory)
96+
assert total == 2
97+
assert success == 2
98+
99+
100+
def test_encrypt_sensitive_and_decrypt_sensitive_data(temp_dir: Path):
101+
data = {
102+
"username": "bob",
103+
"password": "secret123",
104+
"token": None, # None should be ignored
105+
}
106+
sensitive = ["password", "token"]
107+
108+
enc = encrypt_sensitive_data(data, sensitive)
109+
assert enc["username"] == "bob"
110+
assert enc.get("password_encrypted") is True
111+
# base64 string stored
112+
enc_val = enc["password"]
113+
assert isinstance(enc_val, str)
114+
115+
dec = decrypt_sensitive_data(enc)
116+
assert dec.get("password_encrypted") is None
117+
assert dec["password"] == "secret123"
118+
119+
120+
def test_init_with_password_derivation_creates_keys(temp_dir: Path):
121+
# ensure no keys exist
122+
keys_dir = Path("config") / "encryption_keys"
123+
if keys_dir.exists():
124+
shutil.rmtree(keys_dir)
125+
126+
mgr = EncryptionManager(password="passw0rd")
127+
# current_keys.json should be created
128+
key_file = keys_dir / "current_keys.json"
129+
assert key_file.exists()
130+
with open(key_file, "r") as f:
131+
serialized = json.load(f)
132+
assert isinstance(serialized, list) and len(serialized) >= 1
133+
# keys are base64 strings
134+
base64.b64decode(serialized[0]["key"]) # will raise if invalid
135+
136+
137+
def test_reencrypt_directory_handles_empty_and_invalid_path(temp_dir: Path):
138+
mgr = EncryptionManager()
139+
140+
empty_dir = Path("empty")
141+
empty_dir.mkdir()
142+
succ, tot = mgr.reencrypt_directory(empty_dir)
143+
assert (succ, tot) == (0, 0)
144+
145+
# non-existing path -> method catches and returns (0,0)
146+
succ2, tot2 = mgr.reencrypt_directory(Path("no_such_dir"))
147+
assert (succ2, tot2) == (0, 0)

0 commit comments

Comments
 (0)