From a71773976dc31d451b3dfe7b61e89731637e6cac Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Tue, 14 Jul 2026 22:54:09 +0500 Subject: [PATCH] fix(auth): Azure DevOps az-CLI token acquisition returns None on undecodable output _acquire_via_az_cli runs 'az account get-access-token' with text=True, so subprocess.run decodes stdout with the locale encoding and raises UnicodeDecodeError (a ValueError sibling, NOT a JSONDecodeError) when the output can't be decoded. That escaped the except (OSError, TimeoutExpired, JSONDecodeError, KeyError) tuple and crashed a helper whose contract is to return str | None. Add UnicodeDecodeError to the tuple. Test patches subprocess.run to raise UnicodeDecodeError and asserts resolve_token returns None (fails before: the error propagated). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/authentication/azure_devops.py | 12 +++++++++++- tests/test_authentication.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/authentication/azure_devops.py b/src/specify_cli/authentication/azure_devops.py index 5d71a1957b..0ccdd5afb3 100644 --- a/src/specify_cli/authentication/azure_devops.py +++ b/src/specify_cli/authentication/azure_devops.py @@ -76,7 +76,17 @@ def _acquire_via_az_cli() -> str | None: payload = _json.loads(result.stdout) token = payload.get("accessToken", "").strip() return token or None - except (OSError, subprocess.TimeoutExpired, _json.JSONDecodeError, KeyError): + except ( + OSError, + subprocess.TimeoutExpired, + _json.JSONDecodeError, + UnicodeDecodeError, + KeyError, + ): + # UnicodeDecodeError: text=True decodes az stdout with the locale + # encoding, which raises (not a JSONDecodeError) if the output isn't + # decodable — this helper's contract is to return None on any + # failure, never to propagate. return None @staticmethod diff --git a/tests/test_authentication.py b/tests/test_authentication.py index e8bc2b6781..84f6d16fbd 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -502,6 +502,19 @@ def test_resolve_token_azure_cli_not_installed_returns_none(self): with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")): assert AzureDevOpsAuth().resolve_token(entry) is None + def test_resolve_token_azure_cli_undecodable_output_returns_none(self): + """Undecodable az output returns None, not a crash. With text=True, + subprocess.run decodes stdout with the locale encoding and raises + UnicodeDecodeError (not a JSONDecodeError) when it can't — the helper's + contract is to return None on any failure.""" + from unittest.mock import patch + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + boom = UnicodeDecodeError("utf-8", b"\xff\xfe", 0, 1, "invalid start byte") + with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=boom): + assert AzureDevOpsAuth().resolve_token(entry) is None + def test_resolve_token_azure_ad_success(self, monkeypatch): """azure-ad acquires token via OAuth2 client credentials.""" from unittest.mock import patch, MagicMock