Skip to content

Commit 14db725

Browse files
pcmxgtisevignyj
andauthored
Improve DUO testability (#139)
* Make duo_api_post into a pass-through call * Improve DUO tests, and clean up methods for easier testing. * Clean up method names for readability * Adjust API calls and tests so that calls to DUO work --------- Co-authored-by: sevignyj <41591249+sevignyj@users.noreply.github.com>
1 parent fa7b00b commit 14db725

File tree

5 files changed

+405
-134
lines changed

5 files changed

+405
-134
lines changed

tests/unit/test_duo.py

Lines changed: 292 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,26 @@
33
"""Unit tests, and local fixtures for DUO module."""
44
from unittest.mock import Mock
55

6+
import pytest
7+
from tokendito.http_client import HTTP_client
68

7-
def test_set_passcode(mocker):
9+
10+
def test_get_passcode(mocker):
811
"""Check if numerical passcode can handle leading zero values."""
912
from tokendito import duo
1013

1114
mocker.patch("tokendito.user.tty_assertion", return_value=True)
1215
mocker.patch("tokendito.user.input", return_value="0123456")
13-
assert duo.set_passcode({"factor": "passcode"}) == "0123456"
16+
assert duo.get_passcode({"factor": "passcode"}) == "0123456"
17+
assert duo.get_passcode({"factor": "PassCode"}) == "0123456"
18+
assert duo.get_passcode({"factor": "push"}) is None
19+
assert duo.get_passcode("pytest") is None
1420

1521

16-
def test_prepare_duo_info():
22+
def test_prepare_info():
1723
"""Test behaviour empty return duo info."""
1824
from tokendito.config import config
19-
from tokendito.duo import prepare_duo_info
25+
from tokendito.duo import prepare_info
2026

2127
selected_okta_factor = {
2228
"_embedded": {
@@ -49,13 +55,17 @@ def test_prepare_duo_info():
4955
"sid": "",
5056
"version": "3.7",
5157
}
52-
assert prepare_duo_info(selected_okta_factor) == expected_duo_info
58+
assert prepare_info(selected_okta_factor) == expected_duo_info
59+
60+
with pytest.raises(SystemExit) as err:
61+
prepare_info({"badresponse": "FAIL"})
62+
assert err.value.code == 1
5363

5464

55-
def test_get_duo_sid(mocker):
65+
def test_get_sid(mocker):
5666
"""Check if got sid correct."""
5767
from tokendito.config import config
58-
from tokendito.duo import get_duo_sid
68+
from tokendito.duo import get_sid
5969

6070
test_duo_info = {
6171
"okta_factor": "okta_factor",
@@ -74,19 +84,290 @@ def test_get_duo_sid(mocker):
7484
duo_api_response = Mock()
7585
duo_api_response.url = test_url
7686

77-
mocker.patch("tokendito.duo.duo_api_post", return_value=duo_api_response)
87+
mocker.patch("tokendito.duo.api_post", return_value=duo_api_response)
7888

79-
duo_sid_info, duo_auth_response = get_duo_sid(test_duo_info)
89+
duo_sid_info, duo_auth_response = get_sid(test_duo_info)
8090

8191
assert duo_sid_info["sid"] == "testval"
8292
assert duo_auth_response.url == test_url
8393

94+
mocker.patch("tokendito.duo.api_post", return_value="FAIL")
95+
with pytest.raises(SystemExit) as err:
96+
get_sid(test_duo_info)
97+
assert err.value.code == 2
98+
8499

85100
def test_get_mfa_response():
86101
"""Test if mfa verify correctly."""
87102
from tokendito.duo import get_mfa_response
88103

89104
mfa_result = Mock()
90-
mfa_result.json = Mock(return_value={"response": "test_response"})
91105

92-
assert get_mfa_response(mfa_result) == "test_response"
106+
# Test if response is correct
107+
assert get_mfa_response({"response": "test_value"}) == "test_value"
108+
109+
# Test if response is incorrect
110+
mfa_result = Mock(return_value={"badresponse": "FAIL"})
111+
with pytest.raises(SystemExit) as err:
112+
get_mfa_response(mfa_result)
113+
assert err.value.code == 1
114+
115+
# Test no key available
116+
with pytest.raises(SystemExit) as err:
117+
get_mfa_response({"pytest": "FAIL"})
118+
assert err.value.code == 1
119+
120+
# Test generic failure
121+
with pytest.raises(SystemExit) as err:
122+
get_mfa_response(Mock(return_value="FAIL"))
123+
assert err.value.code == 1
124+
125+
126+
def test_api_post(mocker):
127+
"""Test if duo api post correctly."""
128+
from tokendito.duo import api_post
129+
130+
mock_post = mocker.patch("requests.Session.post")
131+
mock_resp = mocker.Mock()
132+
mock_resp.status_code = 201
133+
mock_resp.json.return_value = {"status": "pytest"}
134+
mock_post.return_value = mock_resp
135+
136+
response = api_post("https://pytest/")
137+
assert response == {"status": "pytest"}
138+
139+
140+
def test_get_devices(mocker):
141+
"""Test that we can get a list of devices."""
142+
from tokendito.duo import get_devices
143+
144+
mock_resp = mocker.Mock()
145+
mock_resp.status_code = 200
146+
mock_resp.content = "<html></html>"
147+
148+
# Test generic failure or empty response
149+
with pytest.raises(SystemExit) as err:
150+
get_devices(mock_resp)
151+
assert err.value.code == 2
152+
153+
# Test no devices in list
154+
mock_resp.content = """
155+
<select name='device'>
156+
<option value='pytest_val'>pytest_text</option>
157+
</select>
158+
"""
159+
assert get_devices(mock_resp) == []
160+
161+
# Test devices in list
162+
mock_resp.content = """
163+
<select name='device'>
164+
<option value='pytest_device'>pytest_device_name</option>
165+
</select>
166+
<fieldset data-device-index='pytest_device'>
167+
<input name='factor' value='factor_type'>
168+
</fieldset>
169+
"""
170+
assert get_devices(mock_resp) == [
171+
{"device": "pytest_device - pytest_device_name", "factor": "factor_type"}
172+
]
173+
174+
175+
def test_parse_mfa_challenge():
176+
"""Test parsing the response to the challenge."""
177+
from tokendito.duo import parse_mfa_challenge
178+
179+
mfa_challenge = Mock()
180+
181+
# Test successful challenge
182+
assert parse_mfa_challenge({"stat": "OK", "response": {"txid": "pytest"}}) == "pytest"
183+
184+
# Test error
185+
mfa_challenge.json = Mock(return_value={"stat": "OK", "response": "error"})
186+
with pytest.raises(SystemExit) as err:
187+
parse_mfa_challenge(mfa_challenge)
188+
assert err.value.code == 1
189+
190+
# Test no key in returned content
191+
with pytest.raises(SystemExit) as err:
192+
parse_mfa_challenge({"pyest": "OK", "badresponse": "error"})
193+
assert err.value.code == 1
194+
195+
# Test no response in returned content
196+
mfa_challenge.json = Mock(return_value={"stat": "OK", "badresponse": "error"})
197+
with pytest.raises(SystemExit) as err:
198+
parse_mfa_challenge(mfa_challenge)
199+
assert err.value.code == 1
200+
201+
# Test failure
202+
with pytest.raises(SystemExit) as err:
203+
parse_mfa_challenge({"stat": "fail", "response": {"txid": "pytest_error"}})
204+
assert err.value.code == 1
205+
206+
# Test API failure
207+
mfa_challenge.json = Mock(return_value={"stat": "fail", "response": {"txid": "error"}})
208+
with pytest.raises(SystemExit) as err:
209+
parse_mfa_challenge(mfa_challenge)
210+
assert err.value.code == 1
211+
212+
213+
def test_mfa_challenge(mocker):
214+
"""TODO: Test MFA challenge."""
215+
from tokendito.duo import mfa_challenge
216+
217+
with pytest.raises(SystemExit) as err:
218+
mfa_challenge(None, None, None)
219+
assert err.value.code == 2
220+
221+
duo_info = {
222+
"okta_factor": "okta_factor",
223+
"factor_id": 1234,
224+
"state_token": 12345,
225+
"okta_callback_url": "http://test.okta.href",
226+
"tx": "pytest_tx",
227+
"tile_sig": "pytest_tile_sig",
228+
"parent": "pytest_parent",
229+
"host": "pytest_host",
230+
"sid": "pytest_sid",
231+
"version": "3.7",
232+
}
233+
passcode = "pytest_passcode"
234+
mfa_option = {"factor": "pytest_factor", "device": "pytest_device - pytest_device_name"}
235+
236+
mocker.patch(
237+
"tokendito.duo.api_post", return_value={"stat": "OK", "response": {"txid": "pytest_txid"}}
238+
)
239+
240+
txid = mfa_challenge(duo_info, mfa_option, passcode)
241+
assert txid == "pytest_txid"
242+
243+
244+
def test_parse_challenge():
245+
"""Test that we can parse a challenge."""
246+
from tokendito.duo import parse_challenge
247+
248+
verify_mfa = {"status": "SUCCESS", "result": "SUCCESS", "reason": "pytest"}
249+
assert parse_challenge(verify_mfa, None) == ("success", "pytest")
250+
251+
verify_mfa = {"status": "UNKNOWN", "reason": "UNKNOWN"}
252+
challenge_result = {"result": "PYTEST"}
253+
assert parse_challenge(verify_mfa, challenge_result) == (challenge_result, "UNKNOWN")
254+
255+
256+
@pytest.mark.parametrize(
257+
"return_value,side_effect,expected",
258+
[
259+
(("success", "pytest"), None, "pytest"),
260+
((None, None), [(None, None), ("success", "pytest")], "pytest"),
261+
(("failure", "pytest"), None, SystemExit),
262+
],
263+
)
264+
def test_mfa_verify(mocker, return_value, side_effect, expected):
265+
"""Test MFA challenge completion.
266+
267+
side_effect is utilized to return different values on different iterations.
268+
"""
269+
from tokendito.duo import mfa_verify
270+
271+
mocker.patch.object(HTTP_client, "post", return_value=None)
272+
mocker.patch("time.sleep", return_value=None)
273+
mocker.patch("tokendito.duo.get_mfa_response", return_value="pytest")
274+
mocker.patch(
275+
"tokendito.duo.parse_challenge", return_value=return_value, side_effect=side_effect
276+
)
277+
278+
duo_info = {"host": "pytest_host", "sid": "pytest_sid"}
279+
txid = "pytest_txid"
280+
281+
if expected == SystemExit:
282+
# Test failure as exit condition
283+
with pytest.raises(expected) as err:
284+
mfa_verify(duo_info, txid)
285+
assert err.value.code == 2
286+
else:
287+
# Test success, failure, and iterated calls
288+
assert mfa_verify(duo_info, txid) == expected
289+
290+
291+
def test_factor_callback(mocker):
292+
"""Test submitting factor to callback API."""
293+
from tokendito.duo import factor_callback
294+
295+
duo_info = {"host": "pytest_host", "sid": "pytest_sid", "tile_sig": "pytest_tile_sig"}
296+
verify_mfa = {"result_url": "/pytest_result_url"}
297+
298+
# Test successful retrieval of the cookie
299+
duo_api_response = {
300+
"stat": "OK",
301+
"response": {"txid": "pytest_txid", "cookie": "pytest_cookie"},
302+
}
303+
mocker.patch("tokendito.duo.api_post", return_value=duo_api_response)
304+
sig_response = factor_callback(duo_info, verify_mfa)
305+
assert sig_response == "pytest_cookie:pytest_tile_sig"
306+
307+
# Test bad data passed in
308+
duo_api_response = "FAIL"
309+
mocker.patch("tokendito.duo.api_post", return_value=duo_api_response)
310+
with pytest.raises(SystemExit) as err:
311+
factor_callback(duo_info, verify_mfa)
312+
assert err.value.code == 2
313+
314+
# Test bad data passed in
315+
duo_api_response = {"stat": "FAIL", "response": {"cookie": "pytest_cookie"}}
316+
duo_info = {"host": "pytest", "sid": "pytest"}
317+
mocker.patch("tokendito.duo.api_post", return_value=duo_api_response)
318+
with pytest.raises(SystemExit) as err:
319+
factor_callback(duo_info, verify_mfa)
320+
assert err.value.code == 2
321+
322+
323+
def test_authenticate(mocker):
324+
"""Test end to end authentication."""
325+
from tokendito.duo import authenticate
326+
327+
mocker.patch(
328+
"tokendito.duo.get_sid",
329+
return_value=(
330+
{
331+
"sid": "pytest",
332+
"host": "pytest",
333+
"state_token": "pytest",
334+
"factor_id": "pytest",
335+
"okta_callback_url": "pytest",
336+
},
337+
"pytest",
338+
),
339+
)
340+
# We mock a lot of functions here, but we're really just testing that the data can flow,
341+
# and that it can be parsed correctly to be sent to the API endpoint.
342+
mocker.patch("tokendito.duo.get_devices", return_value=[{"device": "pytest - device"}])
343+
mocker.patch("tokendito.user.select_preferred_mfa_index", return_value=0)
344+
mocker.patch("tokendito.user.input", return_value="0123456")
345+
mocker.patch("tokendito.duo.mfa_challenge", return_value="txid_pytest")
346+
mocker.patch("tokendito.duo.mfa_verify", return_value={"result_url": "/pytest_result_url"})
347+
mocker.patch("tokendito.duo.api_post", return_value=None)
348+
mocker.patch("tokendito.duo.factor_callback", return_value="pytest_cookie:pytest_tile_sig")
349+
selected_okta_factor = {
350+
"_embedded": {
351+
"factor": {
352+
"_embedded": {
353+
"verification": {
354+
"_links": {
355+
"complete": {"href": "http://test.okta.href"},
356+
"script": {"href": "python-v3.7"},
357+
},
358+
"signature": "fdsafdsa:fdsfdfds:fdsfdsfds",
359+
"host": "test_host",
360+
}
361+
},
362+
"id": 1234,
363+
}
364+
},
365+
"stateToken": 12345,
366+
}
367+
368+
res = authenticate(selected_okta_factor)
369+
assert {
370+
"id": "pytest",
371+
"sig_response": "pytest_cookie:pytest_tile_sig",
372+
"stateToken": "pytest",
373+
} == res

tests/unit/test_okta.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,15 @@ def test_mfa_provider_type(
8989
mock_response = {"sessionToken": session_token}
9090
mocker.patch.object(HTTP_client, "post", return_value=mock_response)
9191

92-
mocker.patch("tokendito.duo.duo_api_post", return_value=None)
92+
mocker.patch("tokendito.duo.api_post", return_value=None)
9393

9494
payload = {"x": "y", "t": "z"}
95-
callback_url = "https://www.acme.org"
9695
selected_mfa_option = 1
9796
mfa_challenge_url = 1
9897
primary_auth = 1
9998
pytest_config = Config()
10099

101-
mocker.patch(
102-
"tokendito.duo.authenticate_duo",
103-
return_value=(payload, sample_headers, callback_url),
104-
)
100+
mocker.patch("tokendito.duo.authenticate", return_value=payload)
105101
mocker.patch("tokendito.okta.push_approval", return_value={"sessionToken": session_token})
106102
mocker.patch("tokendito.okta.totp_approval", return_value={"sessionToken": session_token})
107103

@@ -128,7 +124,6 @@ def test_bad_mfa_provider_type(mocker, sample_headers):
128124

129125
pytest_config = Config()
130126
payload = {"x": "y", "t": "z"}
131-
callback_url = "https://www.acme.org"
132127
selected_mfa_option = 1
133128
mfa_challenge_url = 1
134129
primary_auth = 1
@@ -140,10 +135,7 @@ def test_bad_mfa_provider_type(mocker, sample_headers):
140135
mock_response = Mock()
141136
mock_response.json.return_value = mfa_verify
142137

143-
mocker.patch(
144-
"tokendito.duo.authenticate_duo",
145-
return_value=(payload, sample_headers, callback_url),
146-
)
138+
mocker.patch("tokendito.duo.authenticate", return_value=payload)
147139
mocker.patch.object(HTTP_client, "post", return_value=mock_response)
148140
mocker.patch("tokendito.okta.totp_approval", return_value=mfa_verify)
149141

0 commit comments

Comments
 (0)