Skip to content

Commit a460396

Browse files
author
r0BIT
committed
test: improve test coverage to 60%+ with 1207 tests
- Add comprehensive test suites for DPAPI decryptor, looter, parser - Add tests for LAPS models, helpers, exceptions, decryption, parsing - Add tests for BloodHound API, auth, connector, upload - Add tests for SMB connection, tasks, credguard, task_rpc - Add tests for OpenGraph builder and writer - Add tests for engine online, offline, helpers - Add tests for SID resolver, cache manager, network utils - Add tests for console utilities, config model, output writer - Expand existing test files with additional coverage - Final coverage: 60.19% (target: >60%)
1 parent 915a2c8 commit a460396

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+13037
-225
lines changed

tests/test_bh_api.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
"""
2+
Test suite for BloodHound API utility functions.
3+
4+
Tests cover:
5+
- bhce_signed_request function
6+
- get_bloodhound_token function
7+
"""
8+
9+
import pytest
10+
from unittest.mock import MagicMock, patch
11+
import base64
12+
import datetime
13+
import hashlib
14+
import hmac
15+
16+
from taskhound.utils.bh_api import (
17+
bhce_signed_request,
18+
get_bloodhound_token,
19+
)
20+
21+
22+
# ============================================================================
23+
# Test: bhce_signed_request
24+
# ============================================================================
25+
26+
27+
class TestBhceSignedRequest:
28+
"""Tests for bhce_signed_request function"""
29+
30+
@patch('taskhound.utils.bh_api.requests.request')
31+
@patch('taskhound.utils.bh_api.datetime.datetime')
32+
def test_basic_get_request(self, mock_datetime, mock_request):
33+
"""Should make a signed GET request"""
34+
mock_now = MagicMock()
35+
mock_now.isoformat.return_value = "2024-01-15T10:30:00+00:00"
36+
mock_datetime.now.return_value.astimezone.return_value = mock_now
37+
38+
mock_response = MagicMock()
39+
mock_request.return_value = mock_response
40+
41+
result = bhce_signed_request(
42+
method="GET",
43+
uri="/api/version",
44+
base_url="https://bloodhound.example.com",
45+
api_key="secret_api_key",
46+
api_key_id="key_id_123"
47+
)
48+
49+
assert result == mock_response
50+
mock_request.assert_called_once()
51+
call_kwargs = mock_request.call_args[1]
52+
assert call_kwargs["method"] == "GET"
53+
assert call_kwargs["url"] == "https://bloodhound.example.com/api/version"
54+
assert "bhesignature key_id_123" in call_kwargs["headers"]["Authorization"]
55+
56+
@patch('taskhound.utils.bh_api.requests.request')
57+
@patch('taskhound.utils.bh_api.datetime.datetime')
58+
def test_post_with_body(self, mock_datetime, mock_request):
59+
"""Should sign POST request with body"""
60+
mock_now = MagicMock()
61+
mock_now.isoformat.return_value = "2024-01-15T10:30:00+00:00"
62+
mock_datetime.now.return_value.astimezone.return_value = mock_now
63+
64+
mock_response = MagicMock()
65+
mock_request.return_value = mock_response
66+
67+
body = b'{"query": "test"}'
68+
result = bhce_signed_request(
69+
method="POST",
70+
uri="/api/cypher",
71+
base_url="https://bloodhound.example.com",
72+
api_key="secret_api_key",
73+
api_key_id="key_id_123",
74+
body=body
75+
)
76+
77+
assert result == mock_response
78+
call_kwargs = mock_request.call_args[1]
79+
assert call_kwargs["data"] == body
80+
81+
@patch('taskhound.utils.bh_api.requests.request')
82+
@patch('taskhound.utils.bh_api.datetime.datetime')
83+
def test_correct_headers_set(self, mock_datetime, mock_request):
84+
"""Should set correct headers for HMAC auth"""
85+
mock_now = MagicMock()
86+
mock_now.isoformat.return_value = "2024-01-15T10:30:00+00:00"
87+
mock_datetime.now.return_value.astimezone.return_value = mock_now
88+
89+
mock_request.return_value = MagicMock()
90+
91+
bhce_signed_request(
92+
method="GET",
93+
uri="/api/version",
94+
base_url="https://bh.local",
95+
api_key="test_key",
96+
api_key_id="test_id"
97+
)
98+
99+
call_kwargs = mock_request.call_args[1]
100+
headers = call_kwargs["headers"]
101+
102+
assert headers["Authorization"] == "bhesignature test_id"
103+
assert headers["RequestDate"] == "2024-01-15T10:30:00+00:00"
104+
assert "Signature" in headers
105+
assert headers["Content-Type"] == "application/json"
106+
assert headers["Accept"] == "application/json"
107+
108+
@patch('taskhound.utils.bh_api.requests.request')
109+
@patch('taskhound.utils.bh_api.datetime.datetime')
110+
def test_custom_timeout(self, mock_datetime, mock_request):
111+
"""Should use custom timeout"""
112+
mock_now = MagicMock()
113+
mock_now.isoformat.return_value = "2024-01-15T10:30:00+00:00"
114+
mock_datetime.now.return_value.astimezone.return_value = mock_now
115+
116+
mock_request.return_value = MagicMock()
117+
118+
bhce_signed_request(
119+
method="GET",
120+
uri="/api/version",
121+
base_url="https://bh.local",
122+
api_key="test_key",
123+
api_key_id="test_id",
124+
timeout=60
125+
)
126+
127+
call_kwargs = mock_request.call_args[1]
128+
assert call_kwargs["timeout"] == 60
129+
130+
@patch('taskhound.utils.bh_api.requests.request')
131+
@patch('taskhound.utils.bh_api.datetime.datetime')
132+
def test_signature_is_base64_encoded(self, mock_datetime, mock_request):
133+
"""Should produce base64 encoded signature"""
134+
mock_now = MagicMock()
135+
mock_now.isoformat.return_value = "2024-01-15T10:30:00+00:00"
136+
mock_datetime.now.return_value.astimezone.return_value = mock_now
137+
138+
mock_request.return_value = MagicMock()
139+
140+
bhce_signed_request(
141+
method="GET",
142+
uri="/api/version",
143+
base_url="https://bh.local",
144+
api_key="test_key",
145+
api_key_id="test_id"
146+
)
147+
148+
call_kwargs = mock_request.call_args[1]
149+
signature = call_kwargs["headers"]["Signature"]
150+
151+
# Should be valid base64
152+
try:
153+
decoded = base64.b64decode(signature)
154+
assert len(decoded) == 32 # SHA256 produces 32 bytes
155+
except Exception:
156+
pytest.fail("Signature is not valid base64")
157+
158+
159+
# ============================================================================
160+
# Test: get_bloodhound_token
161+
# ============================================================================
162+
163+
164+
class TestGetBloodhoundToken:
165+
"""Tests for get_bloodhound_token function"""
166+
167+
@patch('taskhound.utils.bh_api.requests.post')
168+
def test_successful_login(self, mock_post):
169+
"""Should return session token on successful login"""
170+
mock_response = MagicMock()
171+
mock_response.json.return_value = {
172+
"data": {
173+
"session_token": "token123abc"
174+
}
175+
}
176+
mock_post.return_value = mock_response
177+
178+
result = get_bloodhound_token(
179+
base_url="https://bh.local",
180+
username="admin",
181+
password="password123"
182+
)
183+
184+
assert result == "token123abc"
185+
mock_post.assert_called_once()
186+
call_args = mock_post.call_args
187+
assert call_args[0][0] == "https://bh.local/api/v2/login"
188+
189+
@patch('taskhound.utils.bh_api.requests.post')
190+
def test_correct_login_payload(self, mock_post):
191+
"""Should send correct login payload"""
192+
mock_response = MagicMock()
193+
mock_response.json.return_value = {"data": {"session_token": "tok"}}
194+
mock_post.return_value = mock_response
195+
196+
get_bloodhound_token(
197+
base_url="https://bh.local",
198+
username="testuser",
199+
password="testpass"
200+
)
201+
202+
call_kwargs = mock_post.call_args[1]
203+
assert call_kwargs["json"]["login_method"] == "secret"
204+
assert call_kwargs["json"]["username"] == "testuser"
205+
assert call_kwargs["json"]["secret"] == "testpass"
206+
207+
@patch('taskhound.utils.bh_api.requests.post')
208+
def test_raises_on_http_error(self, mock_post):
209+
"""Should raise on HTTP error"""
210+
mock_response = MagicMock()
211+
mock_response.raise_for_status.side_effect = Exception("401 Unauthorized")
212+
mock_post.return_value = mock_response
213+
214+
with pytest.raises(Exception) as exc_info:
215+
get_bloodhound_token(
216+
base_url="https://bh.local",
217+
username="admin",
218+
password="wrong"
219+
)
220+
221+
assert "401" in str(exc_info.value)
222+
223+
@patch('taskhound.utils.bh_api.requests.post')
224+
def test_raises_on_invalid_response_missing_data(self, mock_post):
225+
"""Should raise ValueError on invalid response format"""
226+
mock_response = MagicMock()
227+
mock_response.json.return_value = {"error": "something"}
228+
mock_post.return_value = mock_response
229+
230+
with pytest.raises(ValueError) as exc_info:
231+
get_bloodhound_token(
232+
base_url="https://bh.local",
233+
username="admin",
234+
password="pass"
235+
)
236+
237+
assert "missing session_token" in str(exc_info.value)
238+
239+
@patch('taskhound.utils.bh_api.requests.post')
240+
def test_raises_on_invalid_response_missing_token(self, mock_post):
241+
"""Should raise ValueError when session_token missing"""
242+
mock_response = MagicMock()
243+
mock_response.json.return_value = {"data": {"other": "value"}}
244+
mock_post.return_value = mock_response
245+
246+
with pytest.raises(ValueError) as exc_info:
247+
get_bloodhound_token(
248+
base_url="https://bh.local",
249+
username="admin",
250+
password="pass"
251+
)
252+
253+
assert "missing session_token" in str(exc_info.value)
254+
255+
@patch('taskhound.utils.bh_api.requests.post')
256+
def test_custom_timeout(self, mock_post):
257+
"""Should use custom timeout"""
258+
mock_response = MagicMock()
259+
mock_response.json.return_value = {"data": {"session_token": "tok"}}
260+
mock_post.return_value = mock_response
261+
262+
get_bloodhound_token(
263+
base_url="https://bh.local",
264+
username="admin",
265+
password="pass",
266+
timeout=120
267+
)
268+
269+
call_kwargs = mock_post.call_args[1]
270+
assert call_kwargs["timeout"] == 120

0 commit comments

Comments
 (0)