diff --git a/src/specify_cli/authentication/azure_devops.py b/src/specify_cli/authentication/azure_devops.py index 907f1b5e24..91578bd418 100644 --- a/src/specify_cli/authentication/azure_devops.py +++ b/src/specify_cli/authentication/azure_devops.py @@ -5,6 +5,7 @@ import base64 import json as _json import os +import shutil import subprocess from typing import TYPE_CHECKING @@ -71,9 +72,27 @@ def resolve_token(self, entry: AuthConfigEntry) -> str | None: def _acquire_via_az_cli() -> str | None: """Run ``az account get-access-token`` and return the access token.""" try: + # Windows: ``subprocess.run`` calls ``CreateProcess``, which does + # not consult ``PATHEXT``, so a bare ``"az"`` (installed as + # ``az.cmd``) fails with ``WinError 2`` even after ``az login``. + # Resolve via ``shutil.which`` (which honors ``PATHEXT``) so the + # ``.cmd`` shim works. On POSIX this is a harmless lookup that + # returns the same executable. + # + # Require an ABSOLUTE result: on Windows ``shutil.which`` prepends + # the current directory to the search path (unless + # ``NoDefaultCurrentDirectoryInExePath`` is set), so a stray + # ``.\az.cmd`` in the working directory would otherwise be resolved + # ahead of the real Azure CLI and run for a credential operation. A + # legitimate install always resolves to an absolute path, so this + # costs nothing; falling back to the bare ``"az"`` preserves the + # prior behavior (and the existing OSError path) when ``az`` is + # absent. + resolved = shutil.which("az") + az = resolved if resolved and os.path.isabs(resolved) else "az" result = subprocess.run( # noqa: S603, S607 [ - "az", + az, "account", "get-access-token", "--resource", diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 06523c6f26..374cf0c798 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -494,6 +494,51 @@ def test_resolve_token_azure_cli_failure_returns_none(self): with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result): assert AzureDevOpsAuth().resolve_token(entry) is None + def test_resolve_token_azure_cli_resolves_executable(self): + """The az executable is resolved via shutil.which before invocation, so + the .cmd/.bat shim on Windows (CreateProcess ignores PATHEXT) is used.""" + from unittest.mock import patch, MagicMock + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + result = MagicMock() + result.returncode = 0 + result.stdout = '{"accessToken": "tok"}' + with patch( + "specify_cli.authentication.azure_devops.shutil.which", + return_value=r"C:\Program Files\az\wbin\az.CMD", + ), patch( + "specify_cli.authentication.azure_devops.subprocess.run", + return_value=result, + ) as run: + assert AzureDevOpsAuth().resolve_token(entry) == "tok" + argv = run.call_args.args[0] + assert argv[0] == r"C:\Program Files\az\wbin\az.CMD" + assert argv[1:4] == ["account", "get-access-token", "--resource"] + + @pytest.mark.parametrize("which_result", [None, r".\az.CMD", "az.cmd", "./az"]) + def test_resolve_token_azure_cli_falls_back_to_bare_az(self, which_result): + """Fall back to the bare "az" when shutil.which finds nothing OR returns a + NON-ABSOLUTE path. On Windows shutil.which searches the current directory + first, so a stray .\\az.cmd must never be executed for a credential + operation; the bare name also preserves the not-installed OSError path.""" + from unittest.mock import patch, MagicMock + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + result = MagicMock() + result.returncode = 0 + result.stdout = '{"accessToken": "tok"}' + with patch( + "specify_cli.authentication.azure_devops.shutil.which", + return_value=which_result, + ), patch( + "specify_cli.authentication.azure_devops.subprocess.run", + return_value=result, + ) as run: + assert AzureDevOpsAuth().resolve_token(entry) == "tok" + assert run.call_args.args[0][0] == "az", which_result + def test_resolve_token_azure_cli_not_installed_returns_none(self): """azure-cli returns None when az is not installed.""" from unittest.mock import patch