Skip to content
Merged
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
22 changes: 16 additions & 6 deletions src/apm_cli/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "
Expand All @@ -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)}"
Expand Down
124 changes: 124 additions & 0 deletions tests/unit/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Comment thread
coakenfold marked this conversation as resolved.
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}"
)
Comment thread
coakenfold marked this conversation as resolved.
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):
Comment thread
coakenfold marked this conversation as resolved.
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}"
)
Comment thread
coakenfold marked this conversation as resolved.

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}"
)
Loading