1010from pydantic import SecretStr
1111
1212from divbase_cli .cli_config import cli_settings
13+ from divbase_cli .cli_exceptions import AuthenticationError
1314from 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