Skip to content

Commit e1cc95e

Browse files
committed
test: Add unit tests for credential_store.py
#13 Branch: Profiles-13 Signed-off-by: Gabe Goodhart <ghart@us.ibm.com>
1 parent a6a9920 commit e1cc95e

1 file changed

Lines changed: 256 additions & 0 deletions

File tree

tests/test_credential_store.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# -*- coding: utf-8 -*-
2+
"""Location: ./tests/test_credential_store.py
3+
Copyright 2025
4+
SPDX-License-Identifier: Apache-2.0
5+
Authors: Gabe Goodhart
6+
7+
Tests for credential store functionality.
8+
"""
9+
10+
# Standard
11+
import json
12+
13+
# Third-Party
14+
from cryptography.hazmat.backends import default_backend
15+
from cryptography.hazmat.primitives import hashes
16+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
17+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
18+
19+
# First-Party
20+
from cforge.credential_store import (
21+
decrypt_credential_data,
22+
get_credential_store_path,
23+
get_encryption_key_path,
24+
load_encryption_key,
25+
load_profile_credentials,
26+
)
27+
28+
29+
class TestCredentialStorePaths:
30+
"""Tests for credential store path functions."""
31+
32+
def test_get_credential_store_path(self, mock_settings):
33+
"""Test getting the credential store path."""
34+
path = get_credential_store_path()
35+
assert path == mock_settings.contextforge_home / "context-forge-credentials.json"
36+
37+
def test_get_encryption_key_path(self, mock_settings):
38+
"""Test getting the encryption key path."""
39+
path = get_encryption_key_path()
40+
assert path == mock_settings.contextforge_home / "context-forge-keys.json"
41+
42+
43+
class TestEncryptionKeyLoading:
44+
"""Tests for encryption key loading."""
45+
46+
def test_load_encryption_key_success(self, mock_settings):
47+
"""Test loading encryption key successfully."""
48+
key_path = get_encryption_key_path()
49+
key_path.parent.mkdir(parents=True, exist_ok=True)
50+
key_data = {"encryptionKey": "test-encryption-key-12345"} # gitleaks:allow
51+
key_path.write_text(json.dumps(key_data), encoding="utf-8")
52+
53+
key = load_encryption_key()
54+
assert key == "test-encryption-key-12345" # gitleaks:allow
55+
56+
def test_load_encryption_key_missing_file(self, mock_settings):
57+
"""Test loading encryption key when file doesn't exist."""
58+
key = load_encryption_key()
59+
assert key is None
60+
61+
def test_load_encryption_key_invalid_json(self, mock_settings):
62+
"""Test loading encryption key with invalid JSON."""
63+
key_path = get_encryption_key_path()
64+
key_path.parent.mkdir(parents=True, exist_ok=True)
65+
key_path.write_text("invalid json", encoding="utf-8")
66+
67+
key = load_encryption_key()
68+
assert key is None
69+
70+
71+
class TestCredentialDecryption:
72+
"""Tests for credential decryption."""
73+
74+
def _encrypt_data(self, data: str, encryption_key: str) -> bytes:
75+
"""Helper to encrypt data using the same format as electron-store/conf."""
76+
import os
77+
78+
# Generate random IV
79+
iv = os.urandom(16)
80+
81+
# Derive key using PBKDF2
82+
kdf = PBKDF2HMAC(
83+
algorithm=hashes.SHA512(),
84+
length=32,
85+
salt=iv,
86+
iterations=10_000,
87+
backend=default_backend(),
88+
)
89+
key = kdf.derive(encryption_key.encode("utf-8"))
90+
91+
# Encrypt using AES-256-CBC
92+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
93+
encryptor = cipher.encryptor()
94+
95+
# Add PKCS7 padding
96+
data_bytes = data.encode("utf-8")
97+
padding_length = 16 - (len(data_bytes) % 16)
98+
padded_data = data_bytes + bytes([padding_length] * padding_length)
99+
100+
encrypted = encryptor.update(padded_data) + encryptor.finalize()
101+
102+
# Format: [IV][':'][encrypted data]
103+
return iv + b":" + encrypted
104+
105+
def test_decrypt_credential_data_success(self):
106+
"""Test successful credential decryption."""
107+
test_data = '{"test": "data"}'
108+
encryption_key = "test-key-12345"
109+
110+
encrypted = self._encrypt_data(test_data, encryption_key)
111+
decrypted = decrypt_credential_data(encrypted, encryption_key)
112+
113+
assert decrypted == test_data
114+
115+
def test_decrypt_credential_data_wrong_key(self):
116+
"""Test decryption with wrong key fails gracefully."""
117+
test_data = '{"test": "data"}'
118+
encryption_key = "test-key-12345"
119+
wrong_key = "wrong-key-67890" # gitleaks:allow
120+
121+
encrypted = self._encrypt_data(test_data, encryption_key)
122+
decrypted = decrypt_credential_data(encrypted, wrong_key)
123+
124+
# Decryption should fail and return None or not match original
125+
assert decrypted is None or decrypted != test_data
126+
127+
def test_decrypt_credential_data_invalid_format(self):
128+
"""Test decryption with invalid data format."""
129+
decrypted = decrypt_credential_data(b"invalid data", "test-key")
130+
assert decrypted is None
131+
132+
133+
class TestLoadProfileCredentials:
134+
"""Tests for loading profile credentials."""
135+
136+
def test_load_profile_credentials_success(self, mock_settings):
137+
"""Test loading profile credentials successfully."""
138+
profile_id = "test-profile-123"
139+
encryption_key = "test-encryption-key"
140+
credentials_data = {profile_id: {"email": "test@example.com", "password": "test-password"}}
141+
142+
# Setup encryption key
143+
key_path = get_encryption_key_path()
144+
key_path.parent.mkdir(parents=True, exist_ok=True)
145+
key_path.write_text(json.dumps({"encryptionKey": encryption_key}), encoding="utf-8")
146+
147+
# Setup encrypted credentials
148+
cred_path = get_credential_store_path()
149+
encrypted_data = self._encrypt_data(json.dumps(credentials_data), encryption_key)
150+
cred_path.write_bytes(encrypted_data)
151+
152+
# Load credentials
153+
creds = load_profile_credentials(profile_id)
154+
assert creds is not None
155+
assert creds["email"] == "test@example.com"
156+
assert creds["password"] == "test-password"
157+
158+
def test_load_profile_credentials_no_key(self, mock_settings):
159+
"""Test loading credentials when encryption key is missing."""
160+
creds = load_profile_credentials("test-profile")
161+
assert creds is None
162+
163+
def test_load_profile_credentials_no_store(self, mock_settings):
164+
"""Test loading credentials when credential store is missing."""
165+
# Setup encryption key only
166+
key_path = get_encryption_key_path()
167+
key_path.parent.mkdir(parents=True, exist_ok=True)
168+
key_path.write_text(json.dumps({"encryptionKey": "test-key"}), encoding="utf-8")
169+
170+
creds = load_profile_credentials("test-profile")
171+
assert creds is None
172+
173+
def test_load_profile_credentials_profile_not_found(self, mock_settings):
174+
"""Test loading credentials for non-existent profile."""
175+
encryption_key = "test-encryption-key"
176+
credentials_data = {"other-profile": {"email": "other@example.com", "password": "other-password"}}
177+
178+
# Setup encryption key
179+
key_path = get_encryption_key_path()
180+
key_path.parent.mkdir(parents=True, exist_ok=True)
181+
key_path.write_text(json.dumps({"encryptionKey": encryption_key}), encoding="utf-8")
182+
183+
# Setup encrypted credentials
184+
cred_path = get_credential_store_path()
185+
encrypted_data = self._encrypt_data(json.dumps(credentials_data), encryption_key)
186+
cred_path.write_bytes(encrypted_data)
187+
188+
# Try to load non-existent profile
189+
creds = load_profile_credentials("test-profile")
190+
assert creds is None
191+
192+
def test_load_profile_credentials_invalid_json(self, mock_settings):
193+
"""Test loading credentials when decrypted data is invalid JSON."""
194+
encryption_key = "test-encryption-key"
195+
196+
# Setup encryption key
197+
key_path = get_encryption_key_path()
198+
key_path.parent.mkdir(parents=True, exist_ok=True)
199+
key_path.write_text(json.dumps({"encryptionKey": encryption_key}), encoding="utf-8")
200+
201+
# Setup encrypted credentials with invalid JSON
202+
cred_path = get_credential_store_path()
203+
encrypted_data = self._encrypt_data("invalid json {", encryption_key)
204+
cred_path.write_bytes(encrypted_data)
205+
206+
# Try to load - should handle JSON error gracefully
207+
creds = load_profile_credentials("test-profile")
208+
assert creds is None
209+
210+
def test_load_profile_credentials_decryption_fails(self, mock_settings):
211+
"""Test loading credentials when decryption fails."""
212+
encryption_key = "test-encryption-key"
213+
214+
# Setup encryption key
215+
key_path = get_encryption_key_path()
216+
key_path.parent.mkdir(parents=True, exist_ok=True)
217+
key_path.write_text(json.dumps({"encryptionKey": encryption_key}), encoding="utf-8")
218+
219+
# Setup credential store with corrupted data
220+
cred_path = get_credential_store_path()
221+
cred_path.write_bytes(b"corrupted data that will fail decryption")
222+
223+
# Try to load - should handle decryption failure gracefully
224+
creds = load_profile_credentials("test-profile")
225+
assert creds is None
226+
227+
def _encrypt_data(self, data: str, encryption_key: str) -> bytes:
228+
"""Helper to encrypt data using the same format as electron-store/conf."""
229+
import os
230+
231+
# Generate random IV
232+
iv = os.urandom(16)
233+
234+
# Derive key using PBKDF2
235+
kdf = PBKDF2HMAC(
236+
algorithm=hashes.SHA512(),
237+
length=32,
238+
salt=iv,
239+
iterations=10_000,
240+
backend=default_backend(),
241+
)
242+
key = kdf.derive(encryption_key.encode("utf-8"))
243+
244+
# Encrypt using AES-256-CBC
245+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
246+
encryptor = cipher.encryptor()
247+
248+
# Add PKCS7 padding
249+
data_bytes = data.encode("utf-8")
250+
padding_length = 16 - (len(data_bytes) % 16)
251+
padded_data = data_bytes + bytes([padding_length] * padding_length)
252+
253+
encrypted = encryptor.update(padded_data) + encryptor.finalize()
254+
255+
# Format: [IV][':'][encrypted data]
256+
return iv + b":" + encrypted

0 commit comments

Comments
 (0)