Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/specify_cli/authentication/azure_devops.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import base64
import json as _json
import os
import shutil
import subprocess
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down