@@ -471,6 +471,138 @@ def test_no_fallback_when_fallback_cmd_not_set(self, mocker):
471471 assert mock_run .call_count == 1
472472
473473
474+ class TestDatabricksCliForceRefresh :
475+ """Tests for --force-refresh support in DatabricksCliTokenSource."""
476+
477+ @staticmethod
478+ def _make_process_error (stderr : str , stdout : str = "" ):
479+ import subprocess
480+
481+ err = subprocess .CalledProcessError (1 , ["databricks" ])
482+ err .stdout = stdout .encode ()
483+ err .stderr = stderr .encode ()
484+ return err
485+
486+ @staticmethod
487+ def _make_token_source (
488+ * ,
489+ profile = None ,
490+ host = "https://workspace.databricks.com" ,
491+ cli_path = "/path/to/databricks" ,
492+ ):
493+ """Build a DatabricksCliTokenSource by mocking only the executable check."""
494+ mock_cfg = Mock ()
495+ mock_cfg .profile = profile
496+ mock_cfg .host = host
497+ mock_cfg .databricks_cli_path = cli_path
498+ mock_cfg .disable_async_token_refresh = True
499+ mock_cfg .scopes = None
500+ mock_cfg .get_scopes = Mock (return_value = ["all-apis" ])
501+ mock_cfg .client_type = ClientType .WORKSPACE
502+ mock_cfg .account_id = None
503+ return credentials_provider .DatabricksCliTokenSource (mock_cfg )
504+
505+ def _valid_response_json (self , access_token = "fresh-token" ):
506+ import json
507+
508+ expiry = (datetime .now () + timedelta (hours = 1 )).strftime ("%Y-%m-%dT%H:%M:%S" )
509+ return json .dumps ({"access_token" : access_token , "token_type" : "Bearer" , "expiry" : expiry })
510+
511+ def test_force_refresh_always_tried_first (self , mocker ):
512+ """refresh() always tries --force-refresh first."""
513+ ts = self ._make_token_source ()
514+
515+ mock_run = mocker .patch ("databricks.sdk.credentials_provider._run_subprocess" )
516+ mock_run .return_value = Mock (stdout = self ._valid_response_json ("refreshed" ).encode ())
517+
518+ token = ts .refresh ()
519+ assert token .access_token == "refreshed"
520+ assert mock_run .call_count == 1
521+
522+ cmd = mock_run .call_args [0 ][0 ]
523+ assert "--force-refresh" in cmd
524+
525+ def test_force_refresh_fallback_when_unsupported (self , mocker ):
526+ """Old CLI without --force-refresh: falls back to cmd without --force-refresh."""
527+ ts = self ._make_token_source ()
528+
529+ mock_run = mocker .patch ("databricks.sdk.credentials_provider._run_subprocess" )
530+ mock_run .side_effect = [
531+ self ._make_process_error ("Error: unknown flag: --force-refresh" ),
532+ Mock (stdout = self ._valid_response_json ("fallback" ).encode ()),
533+ ]
534+
535+ token = ts .refresh ()
536+ assert token .access_token == "fallback"
537+ assert mock_run .call_count == 2
538+
539+ first_cmd = mock_run .call_args_list [0 ][0 ][0 ]
540+ second_cmd = mock_run .call_args_list [1 ][0 ][0 ]
541+ assert "--force-refresh" in first_cmd
542+ assert "--force-refresh" not in second_cmd
543+
544+ def test_profile_fallback_when_unsupported (self , mocker ):
545+ """Old CLI without --profile: force_cmd fails, fallback retries with --host."""
546+ ts = self ._make_token_source (profile = "my-profile" )
547+
548+ mock_run = mocker .patch ("databricks.sdk.credentials_provider._run_subprocess" )
549+ mock_run .side_effect = [
550+ # force_cmd: --profile + --force-refresh → unknown --profile
551+ self ._make_process_error ("Error: unknown flag: --profile" ),
552+ # _refresh_without_force cmd: --profile → unknown --profile
553+ self ._make_process_error ("Error: unknown flag: --profile" ),
554+ # _refresh_without_force fallback_cmd: --host → success
555+ Mock (stdout = self ._valid_response_json ("host-token" ).encode ()),
556+ ]
557+
558+ token = ts .refresh ()
559+ assert token .access_token == "host-token"
560+ assert mock_run .call_count == 3
561+ assert "--host" in mock_run .call_args_list [2 ][0 ][0 ]
562+
563+ def test_two_step_downgrade_both_flags_unsupported (self , mocker ):
564+ """CLI supports neither --force-refresh nor --profile: force_cmd fails, then full fallback."""
565+ ts = self ._make_token_source (profile = "my-profile" )
566+
567+ mock_run = mocker .patch ("databricks.sdk.credentials_provider._run_subprocess" )
568+ mock_run .side_effect = [
569+ # 1st: force_cmd (--profile + --force-refresh) → unknown --force-refresh
570+ self ._make_process_error ("Error: unknown flag: --force-refresh" ),
571+ # 2nd: _refresh_without_force cmd (--profile) → unknown --profile
572+ self ._make_process_error ("Error: unknown flag: --profile" ),
573+ # 3rd: _refresh_without_force fallback_cmd (--host) → success
574+ Mock (stdout = self ._valid_response_json ("plain" ).encode ()),
575+ ]
576+
577+ token = ts .refresh ()
578+ assert token .access_token == "plain"
579+ assert mock_run .call_count == 3
580+
581+ cmds = [call [0 ][0 ] for call in mock_run .call_args_list ]
582+ assert "--force-refresh" in cmds [0 ] and "--profile" in cmds [0 ]
583+ assert "--force-refresh" not in cmds [1 ] and "--profile" in cmds [1 ]
584+ assert "--host" in cmds [2 ]
585+
586+ def test_real_auth_error_does_not_trigger_fallback (self , mocker ):
587+ """Real auth failures (not unknown-flag) surface immediately."""
588+ ts = self ._make_token_source ()
589+
590+ mock_run = mocker .patch ("databricks.sdk.credentials_provider._run_subprocess" )
591+ mock_run .side_effect = self ._make_process_error ("cache: databricks OAuth is not configured for this host" )
592+
593+ with pytest .raises (IOError ) as exc_info :
594+ ts .refresh ()
595+ assert "databricks OAuth is not configured" in str (exc_info .value )
596+ assert mock_run .call_count == 1
597+
598+ def test_get_unsupported_flag_extracts_flag (self ):
599+ """The classifier correctly parses the flag name from CLI error output."""
600+ get = credentials_provider .CliTokenSource ._get_unsupported_flag
601+ assert get (IOError ("Error: unknown flag: --force-refresh" )) == "--force-refresh"
602+ assert get (IOError ("Error: unknown flag: --profile" )) == "--profile"
603+ assert get (IOError ("some other error" )) is None
604+
605+
474606# Tests for cloud-agnostic hosts and removed cloud checks
475607class TestCloudAgnosticHosts :
476608 """Tests that credential providers work with cloud-agnostic hosts after removing is_azure/is_gcp checks."""
0 commit comments