Skip to content

Commit 06bd372

Browse files
committed
tests: unit tests for CLI PAT parsing logic
1 parent 1774a34 commit 06bd372

2 files changed

Lines changed: 160 additions & 3 deletions

File tree

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ env =
1111
# divbase-cli test settings
1212
DIVBASE_CLI_CONFIG_PATH=tests/fixtures/tmp/config.yaml
1313
DIVBASE_CLI_TOKENS_PATH=tests/fixtures/tmp/.fakesecrets
14+
DIVBASE_CLI_PATS_PATH=tests/fixtures/tmp/.fakepat
1415
DIVBASE_KEYRING_SERVICE=divbase-cli-test
1516
DIVBASE_API_URL=http://localhost:8001/api
1617
# API and worker test settings (used in unit tests - as integration/e2e get the settings from docker-compose)

tests/unit/divbase_cli/test_user_auth.py

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@
1010
from pydantic import SecretStr
1111

1212
from divbase_cli.cli_config import cli_settings
13+
from divbase_cli.cli_exceptions import AuthenticationError
1314
from divbase_cli.user_auth import (
15+
PATData,
1416
TokenData,
1517
_delete_stored_jwts,
1618
check_existing_session,
19+
delete_stored_pat,
20+
get_pat_for_authentication,
21+
load_stored_user_pat,
1722
load_user_tokens,
23+
make_authenticated_request,
1824
)
1925

2026
# cli config set to use "divbase-cli-test" (via pytest.ini) as the keyring service name,
@@ -27,7 +33,9 @@ def clean_up_keyring_entries():
2733
"""Clean up any test keyring entries added after each test"""
2834
yield
2935
with contextlib.suppress(NoKeyringError, PasswordDeleteError):
30-
keyring.delete_password(service_name=_TEST_KEYRING_SERVICE, username=cli_settings.KEYRING_USERNAME)
36+
keyring.delete_password(service_name=_TEST_KEYRING_SERVICE, username=cli_settings.KEYRING_TOKENS_USERNAME)
37+
with contextlib.suppress(NoKeyringError, PasswordDeleteError):
38+
keyring.delete_password(service_name=_TEST_KEYRING_SERVICE, username=cli_settings.KEYRING_PATS_USERNAME)
3139

3240

3341
@pytest.fixture
@@ -105,7 +113,7 @@ def test_dump_tokens_falls_back_to_file_when_no_keyring(mock_set_password, mock_
105113
If attempt to dump tokens with keyring fails, tokens should be written to a file with 0600 permissions."""
106114
output = tmp_path / ".secrets"
107115

108-
mock_token_data.dump_tokens(output_path=output)
116+
mock_token_data.dump_tokens(fallback_output_path=output)
109117

110118
assert output.exists()
111119
file_mode = stat.S_IMODE(output.stat().st_mode)
@@ -117,7 +125,7 @@ def test_dump_tokens_falls_back_to_file_on_keyring_error(mock_set_password, mock
117125
"""dump_tokens writes a 0600 file when an unexpected keyring error occurs."""
118126
output = tmp_path / ".secrets"
119127

120-
mock_token_data.dump_tokens(output_path=output)
128+
mock_token_data.dump_tokens(fallback_output_path=output)
121129

122130
assert output.exists()
123131
file_mode = stat.S_IMODE(output.stat().st_mode)
@@ -200,3 +208,151 @@ def test_load_user_tokens_keyring_takes_priority_over_file(mock_get_password, mo
200208
result = load_user_tokens(token_path=token_file)
201209
assert result is not None
202210
assert result.access_token.get_secret_value() == "keyring_access"
211+
212+
213+
@patch("divbase_cli.user_auth.httpx.request")
214+
@patch("divbase_cli.user_auth.load_user_tokens")
215+
def test_make_authenticated_request_uses_jwt_over_pat(mock_load_tokens, mock_request, mock_token_data, monkeypatch):
216+
"""JWT session should take priority over PAT when both are available."""
217+
mock_load_tokens.return_value = mock_token_data
218+
monkeypatch.setattr(cli_settings, "DIVBASE_API_PAT", SecretStr("divbase_pat_should_not_be_used"))
219+
mock_response = MagicMock()
220+
mock_response.raise_for_status = MagicMock()
221+
mock_request.return_value = mock_response
222+
223+
make_authenticated_request(method="GET", divbase_base_url="https://example.com", api_route="v1/test")
224+
225+
auth_header = mock_request.call_args.kwargs["headers"]["Authorization"]
226+
assert auth_header == f"Bearer {mock_token_data.access_token.get_secret_value()}"
227+
assert "divbase_pat_should_not_be_used" not in auth_header
228+
229+
230+
@pytest.fixture
231+
def mock_pat_data():
232+
return PATData(
233+
name="test-pat",
234+
pat=SecretStr("divbase_pat_faketoken"),
235+
pat_expires_at=9999999999,
236+
)
237+
238+
239+
@patch("divbase_cli.user_auth.keyring.set_password", side_effect=NoKeyringError)
240+
def test_dump_pat_data_falls_back_to_file_when_no_keyring(mock_set_password, mock_pat_data, tmp_path):
241+
"""dump_pat_data writes a 0600 file when keyring is unavailable."""
242+
output = tmp_path / ".pat"
243+
244+
mock_pat_data.dump_pat_data(fallback_output_path=output)
245+
246+
assert output.exists()
247+
file_mode = stat.S_IMODE(output.stat().st_mode)
248+
# NOTE: this test has not been tried on windows, the test could fail there as windows doesn't use unix style permissions,
249+
# we can consider dropping the 0600 part of the test if it becomes an issue.
250+
# windows users will use keyring anyway so this is not a problem.
251+
assert file_mode == 0o600
252+
253+
254+
@patch("divbase_cli.user_auth.keyring.get_password")
255+
def test_load_stored_user_pat_reads_from_keyring(mock_get_password, mock_pat_data, tmp_path):
256+
"""load_stored_user_pat returns PAT from keyring when present."""
257+
mock_get_password.return_value = json.dumps(
258+
{
259+
"name": mock_pat_data.name,
260+
"pat": mock_pat_data.pat.get_secret_value(),
261+
"pat_expires_at": mock_pat_data.pat_expires_at,
262+
}
263+
)
264+
265+
result = load_stored_user_pat(pat_fallback_path=tmp_path / ".pat")
266+
267+
assert result is not None
268+
assert result.name == "test-pat"
269+
assert result.pat.get_secret_value() == "divbase_pat_faketoken"
270+
271+
272+
@patch("divbase_cli.user_auth.keyring.get_password", side_effect=NoKeyringError)
273+
def test_load_stored_user_pat_falls_back_to_file_when_no_keyring(mock_get_password, mock_pat_data, tmp_path):
274+
"""load_stored_user_pat reads from file when keyring is unavailable."""
275+
pat_file = tmp_path / ".pat"
276+
pat_file.write_text(
277+
f"name: {mock_pat_data.name}\n"
278+
f"pat: {mock_pat_data.pat.get_secret_value()}\n"
279+
f"pat_expires_at: {mock_pat_data.pat_expires_at}\n"
280+
)
281+
282+
result = load_stored_user_pat(pat_fallback_path=pat_file)
283+
284+
assert result is not None
285+
assert result.name == "test-pat"
286+
287+
288+
@patch("divbase_cli.user_auth.keyring.get_password")
289+
def test_load_stored_user_pat_keyring_takes_priority_over_file(mock_get_password, mock_pat_data, tmp_path):
290+
"""When PAT exists in both keyring and fallback file, keyring takes priority."""
291+
mock_get_password.return_value = json.dumps(
292+
{
293+
"name": "keyring-pat",
294+
"pat": "divbase_pat_keyringtoken",
295+
"pat_expires_at": mock_pat_data.pat_expires_at,
296+
}
297+
)
298+
pat_file = tmp_path / ".pat"
299+
pat_file.write_text(
300+
f"name: {mock_pat_data.name}\n"
301+
f"pat: {mock_pat_data.pat.get_secret_value()}\n"
302+
f"pat_expires_at: {mock_pat_data.pat_expires_at}\n"
303+
)
304+
305+
result = load_stored_user_pat(pat_fallback_path=pat_file)
306+
assert result is not None
307+
assert result.name == "keyring-pat"
308+
309+
310+
def test_get_pat_for_authentication_returns_env_var_pat(monkeypatch):
311+
"""get_pat_for_authentication returns env var PAT when set, without hitting keyring."""
312+
env_pat = SecretStr("divbase_pat_from_env")
313+
monkeypatch.setattr(cli_settings, "DIVBASE_API_PAT", env_pat)
314+
315+
result = get_pat_for_authentication()
316+
assert result is env_pat
317+
318+
319+
@patch("divbase_cli.user_auth.load_stored_user_pat")
320+
def test_get_pat_for_authentication_raises_on_expired_pat(mock_load, mock_pat_data, monkeypatch):
321+
"""get_pat_for_authentication raises AuthenticationError when stored PAT is expired."""
322+
323+
monkeypatch.setattr(cli_settings, "DIVBASE_API_PAT", None)
324+
mock_pat_data.pat_expires_at = int(time.time()) - 60
325+
mock_load.return_value = mock_pat_data
326+
327+
with pytest.raises(AuthenticationError, match="expired"):
328+
get_pat_for_authentication()
329+
330+
331+
@patch("divbase_cli.user_auth.keyring.delete_password", side_effect=PasswordDeleteError)
332+
def test_delete_stored_pat_can_run_multiple_times(mock_delete_password, tmp_path):
333+
"""delete_stored_pat should be idempotent."""
334+
pat_file = tmp_path / ".pat"
335+
delete_stored_pat(pat_file)
336+
delete_stored_pat(pat_file)
337+
delete_stored_pat(pat_file)
338+
339+
340+
@pytest.fixture
341+
def mock_pat_data_no_expiry():
342+
return PATData(
343+
name="no-expiry-pat",
344+
pat=SecretStr("divbase_pat_noexpirytoken"),
345+
pat_expires_at=None,
346+
)
347+
348+
349+
@patch("divbase_cli.user_auth.load_stored_user_pat")
350+
def test_get_pat_for_authentication_no_expiry_pat_returns_pat(mock_load, mock_pat_data_no_expiry, monkeypatch):
351+
"""get_pat_for_authentication returns the PAT without raising when pat_expires_at is None."""
352+
monkeypatch.setattr(cli_settings, "DIVBASE_API_PAT", None)
353+
mock_load.return_value = mock_pat_data_no_expiry
354+
355+
result = get_pat_for_authentication()
356+
357+
assert result is not None
358+
assert result.get_secret_value() == mock_pat_data_no_expiry.pat.get_secret_value()

0 commit comments

Comments
 (0)