diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index fc18d4ca8..1ab109a0e 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -315,15 +315,19 @@ def build_error_context( auth_ctx = self.resolve(host, org) lines: list[str] = [f"Authentication failed for {operation} on {host}."] + host_info = auth_ctx.host_info if auth_ctx.token: lines.append(f"Token was provided (source: {auth_ctx.source}, type: {auth_ctx.token_type}).") - host_info = self.classify_host(host) if host_info.kind == "ghe_cloud": lines.append( "GHE Cloud Data Residency hosts (*.ghe.com) require " "enterprise-scoped tokens. Ensure your PAT is authorized " "for this enterprise." ) + elif host_info.kind == "ado": + lines.append( + "Verify your ADO_APM_PAT is valid and has Code (Read) scope." + ) elif host.lower() == "github.com": lines.append( "If your organization uses SAML SSO or is an EMU org, " @@ -336,12 +340,18 @@ def build_error_context( "authorize your token at https://github.com/settings/tokens" ) else: - lines.append("No token available.") - lines.append( - "Set GITHUB_APM_PAT or GITHUB_TOKEN, or run 'gh auth login'." - ) + if host_info.kind == "ado": + lines.append("Azure DevOps authentication required.") + lines.append( + "Set the ADO_APM_PAT environment variable with a PAT that has Code (Read) scope." + ) + else: + lines.append("No token available.") + lines.append( + "Set GITHUB_APM_PAT or GITHUB_TOKEN, or run 'gh auth login'." + ) - if org: + if org and host_info.kind != "ado": lines.append( f"If packages span multiple organizations, set per-org tokens: " f"GITHUB_APM_PAT_{_org_to_env_suffix(org)}" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index ab63b3b2f..f4a9390e6 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -461,3 +461,127 @@ def test_token_present_shows_source(self): msg = resolver.build_error_context("github.com", "clone") assert "GITHUB_APM_PAT" in msg assert "SAML SSO" in msg + + +# --------------------------------------------------------------------------- +# TestBuildErrorContextADO +# --------------------------------------------------------------------------- + +class TestBuildErrorContextADO: + """build_error_context must give ADO-specific guidance for dev.azure.com hosts. + + Issue #625: missing ADO_APM_PAT is described with a generic GitHub error + message instead of pointing the user at ADO_APM_PAT and Code (Read) scope. + """ + + def test_ado_no_token_mentions_ado_pat(self): + """No ADO_APM_PAT -> error message must mention ADO_APM_PAT.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "ADO_APM_PAT" in msg, ( + f"Expected 'ADO_APM_PAT' in error message, got:\n{msg}" + ) + + def test_ado_no_token_does_not_suggest_github_remediation(self): + """ADO error must not suggest GitHub-specific remediation steps.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "gh auth login" not in msg, ( + f"ADO error message should not mention 'gh auth login', got:\n{msg}" + ) + assert "GITHUB_TOKEN" not in msg, ( + f"ADO error message should not mention 'GITHUB_TOKEN', got:\n{msg}" + ) + assert "GITHUB_APM_PAT_MYORG" not in msg, ( + "ADO error message should not mention per-org GitHub PAT hint " + f"'GITHUB_APM_PAT_MYORG', got:\n{msg}" + ) + + def test_ado_no_token_mentions_code_read_scope(self): + """ADO error must mention Code (Read) scope so user knows what PAT scope to set.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "Code" in msg or "read" in msg.lower(), ( + f"Expected Code (Read) scope guidance in error message, got:\n{msg}" + ) + + def test_ado_no_org_no_token_mentions_ado_pat(self): + """No org argument, no ADO_APM_PAT -> error message must still mention ADO_APM_PAT.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone") + assert "ADO_APM_PAT" in msg, ( + f"Expected 'ADO_APM_PAT' in error message, got:\n{msg}" + ) + + def test_ado_with_token_still_shows_source(self): + """When an ADO token IS present but clone fails, source info is shown.""" + with patch.dict(os.environ, {"ADO_APM_PAT": "mypat"}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "ADO_APM_PAT" in msg, ( + f"Expected token source 'ADO_APM_PAT' in error message, got:\n{msg}" + ) + + def test_ado_with_token_mentions_scope_guidance(self): + """When an ADO token is present but auth fails, PAT validity/scope hint is shown.""" + with patch.dict(os.environ, {"ADO_APM_PAT": "mypat"}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "Code (Read)" in msg, ( + f"Expected Code (Read) scope guidance in error message, got:\n{msg}" + ) + + def test_ado_with_token_does_not_suggest_github_remediation(self): + """When an ADO token is present but auth fails, GitHub SAML guidance must not appear.""" + with patch.dict(os.environ, {"ADO_APM_PAT": "mypat"}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("dev.azure.com", "clone", org="myorg") + assert "SAML" not in msg, ( + f"ADO error should not mention SAML, got:\n{msg}" + ) + assert "github.com/settings/tokens" not in msg, ( + f"ADO error should not mention github.com/settings/tokens, got:\n{msg}" + ) + + def test_visualstudio_com_gets_ado_remediation(self): + """Legacy *.visualstudio.com hosts are also ADO and must get ADO-specific guidance.""" + with patch.dict(os.environ, {}, clear=True): + with patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ): + resolver = AuthResolver() + msg = resolver.build_error_context("myorg.visualstudio.com", "clone") + assert "ADO_APM_PAT" in msg, ( + f"Expected 'ADO_APM_PAT' in error message, got:\n{msg}" + ) + assert "gh auth login" not in msg, ( + f"ADO error should not mention 'gh auth login', got:\n{msg}" + ) + assert "SAML" not in msg, ( + f"ADO error should not mention SAML, got:\n{msg}" + )