diff --git a/tests/test_auth.py b/tests/test_auth.py index 7ff63ec..a06117a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -293,3 +293,54 @@ def test_clear_credentials_removes_config_keys(self): assert "YOUTRACK_VERIFY_SSL" not in config_values assert "YOUTRACK_API_KEY" not in config_values assert config_values.get("OTHER_CONFIG") == "should_remain" + + +@pytest.mark.unit +class TestCredentialsStoredButUnreadable: + """Issue #746: distinguish 'no credentials' from 'stored but unreadable'. + + After an upgrade/move relocates the CLI binary, the OS keychain can deny the + new binary access to the saved token, so load_credentials() returns None even + though a keyring-backed login is recorded in the config file. + """ + + def _manager(self, tmp_path, env_lines): + from youtrack_cli.security import SecurityConfig + + cfg = tmp_path / ".env" + cfg.write_text("\n".join(env_lines) + "\n") + return AuthManager(str(cfg), security_config=SecurityConfig(enable_credential_encryption=True)) + + def test_flags_placeholder_with_unreadable_token(self, tmp_path, monkeypatch): + monkeypatch.delenv("YOUTRACK_TOKEN", raising=False) + mgr = self._manager( + tmp_path, + ["YOUTRACK_BASE_URL=https://yt.example.com", "YOUTRACK_API_KEY=[Stored in keyring]"], + ) + # Keyring cannot return the token (simulates the post-upgrade ACL denial). + mgr.credential_manager.retrieve_credential = MagicMock(return_value=None) + assert mgr.load_credentials() is None + assert mgr.credentials_stored_but_unreadable() is True + + def test_not_flagged_when_token_is_readable(self, tmp_path, monkeypatch): + monkeypatch.delenv("YOUTRACK_TOKEN", raising=False) + mgr = self._manager( + tmp_path, + ["YOUTRACK_BASE_URL=https://yt.example.com", "YOUTRACK_API_KEY=[Stored in keyring]"], + ) + stored = { + "youtrack_base_url": "https://yt.example.com", + "youtrack_token": "perm:REAL-TOKEN", + "youtrack_username": "ryan", + } + mgr.credential_manager.retrieve_credential = MagicMock(side_effect=lambda key: stored.get(key)) + assert mgr.load_credentials() is not None + assert mgr.credentials_stored_but_unreadable() is False + + def test_not_flagged_when_nothing_configured(self, tmp_path, monkeypatch): + monkeypatch.delenv("YOUTRACK_TOKEN", raising=False) + monkeypatch.delenv("YOUTRACK_BASE_URL", raising=False) + mgr = self._manager(tmp_path, ["# no credentials configured"]) + mgr.credential_manager.retrieve_credential = MagicMock(return_value=None) + assert mgr.load_credentials() is None + assert mgr.credentials_stored_but_unreadable() is False diff --git a/youtrack_cli/auth.py b/youtrack_cli/auth.py index 8ee2ec6..d0218a9 100644 --- a/youtrack_cli/auth.py +++ b/youtrack_cli/auth.py @@ -18,7 +18,7 @@ from urllib.parse import urlparse import httpx -from dotenv import load_dotenv +from dotenv import dotenv_values, load_dotenv from pydantic import BaseModel, Field, ValidationError from .client import reset_client_manager_sync @@ -256,6 +256,27 @@ def load_credentials(self) -> AuthConfig | None: except ValidationError: return None + def credentials_stored_but_unreadable(self) -> bool: + """Report whether keyring-backed credentials exist on record but can't be read. + + Distinguishes "never logged in" from "logged in previously, but the token + is no longer readable". The config file records a keyring-backed login as + ``YOUTRACK_API_KEY=[Stored in keyring]`` alongside the base URL, while the + real token lives in the system keyring. If that placeholder is present but + :meth:`load_credentials` returns ``None``, the keyring entry has become + unreadable — e.g. after an upgrade or move relocated the CLI binary and the + OS keychain no longer grants the new binary access. Re-running + ``yt auth login`` re-stores the credential and fixes it. + + Returns: + True if a keyring-backed credential is on record but currently + unreadable; False otherwise (including when credentials load fine). + """ + if self.load_credentials() is not None: + return False + values = dotenv_values(self.config_path) + return bool(values.get("YOUTRACK_BASE_URL")) and values.get("YOUTRACK_API_KEY") == "[Stored in keyring]" + def clear_credentials(self) -> None: """Clear saved authentication credentials from both keyring and file.""" # Clear from keyring diff --git a/youtrack_cli/main.py b/youtrack_cli/main.py index edae877..41d1500 100644 --- a/youtrack_cli/main.py +++ b/youtrack_cli/main.py @@ -1398,6 +1398,18 @@ def status(ctx: click.Context) -> None: try: credentials = auth_manager.load_credentials() if not credentials: + if auth_manager.credentials_stored_but_unreadable(): + console.print( + "⚠ Credentials are stored in the system keyring but can't be read.", + style="yellow", + ) + console.print( + "This can happen after upgrading or moving the CLI — the OS keychain " + "may no longer grant the new binary access to the saved token.", + style="yellow", + ) + console.print("Run 'yt auth login' to re-store your credentials.", style="blue") + return format_and_print_error(CommonErrors.no_credentials()) console.print("Run 'yt auth login' to authenticate first.", style="blue") return