diff --git a/README.md b/README.md index 98456e5..3c76a88 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ There are a few ways to set your credentials in `auth`. 1. Set the environment variable `KOGNIC_CREDENTIALS` to point to your Api Credentials file. The credentials will contain the Client Id and Client Secret. 2. Set to the credentials file path like `auth="~/.config/kognic/credentials.json"` -3. Set environment variables `KOGNIC_CLIENT_ID` and`KOGNIC_CLIENT_SECRET` +3. Set environment variables `KOGNIC_CLIENT_ID` and `KOGNIC_CLIENT_SECRET` 4. Set to credentials tuple `auth=(client_id, client_secret)` +5. Store credentials in the system keyring (see [Storing credentials in the keyring](#storing-credentials-in-the-keyring)) API clients such as the `InputApiClient` accept this `auth` parameter. @@ -61,9 +62,12 @@ The CLI can be configured with a JSON file at `~/.config/kognic/environments.jso ``` Each environment has the following fields: -- `host` - The API hostname, used by `call` to automatically match an environment based on the request URL. +- `host` - The API hostname, used by `kog` to automatically match an environment based on the request URL. - `auth_server` - The OAuth server URL used to fetch tokens. -- `credentials` *(optional)* - Path to a JSON credentials file. Tilde (`~`) is expanded. If omitted, credentials are read from environment variables. +- `credentials` *(optional)* - Where to load credentials from. Three formats are supported: + - A file path: `"~/.config/kognic/credentials-prod.json"` (tilde `~` is expanded) + - A keyring reference: `"keyring://production"` (loads from the system keyring under the named profile) + - Omit entirely: credentials are read from environment variables or the keyring `default` profile `default_environment` specifies which environment to use as a fallback when no `--env` flag is given and no URL match is found. @@ -98,6 +102,82 @@ kognic-auth get-access-token --env demo kognic-auth get-access-token --env demo --server https://custom.server ``` +### credentials + +Manage credentials stored in the system keyring. This is the recommended way to store credentials on a developer machine — more secure than a credentials file and no environment variables needed. + +```bash +kognic-auth credentials load FILE [--env ENV] +kognic-auth credentials clear [--env ENV] +``` + +**`load`** — reads a Kognic credentials JSON file and stores the `client_id` and `client_secret` in the system keyring. + +- `FILE` - Path to a Kognic credentials JSON file (the same format accepted by `--credentials`) +- `--env` - Profile name to store under (default: `default`). Use the environment name from `environments.json` to link the credentials to that environment. + +**`clear`** — removes credentials from the keyring for the given profile. + +**Examples:** +```bash +# Store credentials under the default profile (used as fallback when no credentials are configured) +kognic-auth credentials load ~/Downloads/credentials.json + +# Store per-environment credentials +kognic-auth credentials load ~/Downloads/prod-creds.json --env production +kognic-auth credentials load ~/Downloads/demo-creds.json --env demo + +# Remove credentials +kognic-auth credentials clear --env demo +``` + +### Storing credentials in the keyring + +The system keyring (macOS Keychain, GNOME Keyring, Windows Credential Manager, etc.) is the recommended place to keep credentials on a developer machine. No credentials files on disk, no environment variables in shell profiles. + +**Single-environment setup** — store once, works everywhere: +```bash +kognic-auth credentials load ~/Downloads/credentials.json +# All CLI commands and the SDK will now find credentials automatically +``` + +**Multi-environment setup** — store per-environment credentials and reference them in `environments.json`: +```bash +kognic-auth credentials load ~/Downloads/prod-creds.json --env production +kognic-auth credentials load ~/Downloads/demo-creds.json --env demo +``` + +Then in `~/.config/kognic/environments.json`, reference the keyring profiles with `keyring://`: +```json +{ + "default_environment": "production", + "environments": { + "production": { + "host": "app.kognic.com", + "auth_server": "https://auth.app.kognic.com", + "credentials": "keyring://production" + }, + "demo": { + "host": "demo.kognic.com", + "auth_server": "https://auth.demo.kognic.com", + "credentials": "keyring://demo" + } + } +} +``` + +Now `kog get https://app.kognic.com/v1/projects` automatically picks up the `production` keyring credentials, and `kog get https://demo.kognic.com/v1/projects` picks up `demo`. + +The `keyring://` URI also works in the `auth` parameter of API clients: +```python +client = BaseApiClient(auth="keyring://production") +``` + +**Credential resolution order** — when no explicit `auth` is provided, the SDK tries sources in this order: +1. `KOGNIC_CREDENTIALS` environment variable (path to credentials JSON file) +2. `KOGNIC_CLIENT_ID` + `KOGNIC_CLIENT_SECRET` environment variables +3. System keyring, `default` profile + ### kog Make an authenticated HTTP request to a Kognic API. diff --git a/src/kognic/auth/cli/__init__.py b/src/kognic/auth/cli/__init__.py index fb20ca0..d19f101 100644 --- a/src/kognic/auth/cli/__init__.py +++ b/src/kognic/auth/cli/__init__.py @@ -5,9 +5,9 @@ import sys from types import ModuleType -from kognic.auth.cli import get_access_token +from kognic.auth.cli import credentials, get_access_token -_SUBCOMMANDS: list[ModuleType] = [get_access_token] +_SUBCOMMANDS: list[ModuleType] = [get_access_token, credentials] def create_parser() -> argparse.ArgumentParser: diff --git a/src/kognic/auth/cli/credentials.py b/src/kognic/auth/cli/credentials.py new file mode 100644 index 0000000..48455d7 --- /dev/null +++ b/src/kognic/auth/cli/credentials.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import argparse +import sys + +from kognic.auth.credentials_parser import parse_credentials +from kognic.auth.internal.credentials_store import DEFAULT_PROFILE, clear_credentials, save_credentials + +COMMAND = "credentials" + + +def register_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: + parser = subparsers.add_parser( + COMMAND, + help="Manage stored credentials in the system keyring", + ) + subs = parser.add_subparsers(dest="credentials_action") + + load_p = subs.add_parser("load", help="Load credentials from a JSON file into the system keyring") + load_p.add_argument("file", metavar="FILE", help="Path to credentials JSON file") + load_p.add_argument( + "--env", + default=DEFAULT_PROFILE, + metavar="ENV", + help=f"Keyring profile name to store credentials under (default: {DEFAULT_PROFILE}). " + "Use the environment name from environments.json to link credentials to that environment " + "(e.g. --env production → use 'keyring://production' in your config).", + ) + + clear_p = subs.add_parser("clear", help="Remove stored credentials from the system keyring") + clear_p.add_argument( + "--env", + default=DEFAULT_PROFILE, + metavar="ENV", + help=f"Keyring profile name to clear (default: {DEFAULT_PROFILE}).", + ) + + return parser + + +def run(parsed: argparse.Namespace) -> int: + if parsed.credentials_action == "load": + return _run_load(parsed) + if parsed.credentials_action == "clear": + return _run_clear(parsed) + return 0 + + +def _run_load(parsed: argparse.Namespace) -> int: + try: + creds = parse_credentials(parsed.file) + save_credentials(creds, parsed.env) + print(f"Credentials for client_id={creds.client_id!r} stored in keyring (profile={parsed.env!r})") + return 0 + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except (KeyError, ValueError, RuntimeError) as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def _run_clear(parsed: argparse.Namespace) -> int: + try: + clear_credentials(parsed.env) + print(f"Credentials cleared from keyring (profile={parsed.env!r})") + return 0 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 3fcc1b1..cf9b959 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -11,7 +11,7 @@ COMMAND = "get-access-token" -def register_parser(subparsers: argparse._SubParsersAction) -> None: +def register_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: token_parser = subparsers.add_parser( COMMAND, help="Generate an access token for Kognic API authentication", @@ -44,6 +44,8 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: "Auto will use keyring if available, otherwise file-based caching.", ) + return token_parser + def run(parsed: argparse.Namespace) -> int: try: diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index 26dbd87..05cad84 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import Optional, Union +from kognic.auth.internal import credentials_store + ANY_AUTH_TYPE = Union[str, os.PathLike, tuple, "ApiCredentials", dict, None] REQUIRED_CREDENTIALS_FILE_KEYS = [ @@ -58,6 +60,13 @@ def get_credentials_from_env() -> tuple[Optional[str], Optional[str]]: client_id = os.getenv("KOGNIC_CLIENT_ID") client_secret = os.getenv("KOGNIC_CLIENT_SECRET") + if client_id and client_secret: + return client_id, client_secret + + keyring_creds = credentials_store.load_credentials() + if keyring_creds: + return keyring_creds.client_id, keyring_creds.client_secret + return client_id, client_secret @@ -83,12 +92,24 @@ def resolve_credentials( client_secret = creds.client_secret elif isinstance(auth, (str, os.PathLike)): path = str(auth) - if not path.endswith(".json"): + if path.startswith("keyring://"): + profile = path[len("keyring://") :] + keyring_creds = credentials_store.load_credentials(profile) + if keyring_creds: + client_id, client_secret = keyring_creds.client_id, keyring_creds.client_secret + else: + raise ValueError( + f"No credentials found in keyring for profile '{profile}'. " + f"Run 'kognic-auth credentials load --env {profile}' to store them." + ) + elif not path.endswith(".json"): raise ValueError(f"Bad auth credentials file, must be json: {path}") - creds = parse_credentials(auth) - client_id = creds.client_id - client_secret = creds.client_secret + else: + creds = parse_credentials(auth) + client_id = creds.client_id + client_secret = creds.client_secret elif auth is not None: + # unreasonable type, but we want to be defensive in case of user error raise ValueError(f"Unsupported auth type: {type(auth)}") if not client_id and not client_secret: diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py index 8a446a3..195d6e1 100644 --- a/src/kognic/auth/env_config.py +++ b/src/kognic/auth/env_config.py @@ -33,7 +33,7 @@ def load_kognic_env_config(path: Union[str, os.PathLike] = DEFAULT_ENV_CONFIG_FI environments = {} for name, env_data in data.get("environments", {}).items(): credentials = env_data.get("credentials") - if credentials: + if credentials and not credentials.startswith("keyring://"): credentials = str(Path(credentials).expanduser()) environments[name] = Environment( name=name, diff --git a/src/kognic/auth/internal/credentials_store.py b/src/kognic/auth/internal/credentials_store.py new file mode 100644 index 0000000..b2ae459 --- /dev/null +++ b/src/kognic/auth/internal/credentials_store.py @@ -0,0 +1,77 @@ +"""Keyring-based storage for Kognic API client credentials (full credentials file).""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from kognic.auth.credentials_parser import ApiCredentials + +SERVICE_NAME = "kognic-credentials" +DEFAULT_PROFILE = "default" + +log = logging.getLogger(__name__) + + +def _get_keyring(): + """Return the keyring module if a usable backend is available, else None.""" + try: + import keyring + + backend = keyring.get_keyring() + if "fail" in type(backend).__name__.lower(): + return None + return keyring + except Exception: + return None + + +def load_credentials(profile: str = DEFAULT_PROFILE) -> Optional[ApiCredentials]: + """Load full credentials from keyring, or None if not found.""" + kr = _get_keyring() + if kr is None: + return None + try: + stored = kr.get_password(SERVICE_NAME, profile) + if stored is None: + return None + + from kognic.auth.credentials_parser import parse_credentials + + return parse_credentials(json.loads(stored)) + except Exception: + log.debug("Failed to load credentials from keyring", exc_info=True) + return None + + +def save_credentials(creds: ApiCredentials, profile: str = DEFAULT_PROFILE) -> None: + """Store full credentials in keyring. Raises RuntimeError if keyring is unavailable.""" + kr = _get_keyring() + if kr is None: + raise RuntimeError( + "No usable keyring backend available. " + "Install a keyring backend (e.g. 'pip install keyring') or use environment variables instead." + ) + data = { + "clientId": creds.client_id, + "clientSecret": creds.client_secret, + "email": creds.email, + "userId": creds.user_id, + "issuer": creds.issuer, + } + kr.set_password(SERVICE_NAME, profile, json.dumps(data)) + log.debug("Saved credentials to keyring for profile=%s", profile) + + +def clear_credentials(profile: str = DEFAULT_PROFILE) -> None: + """Remove credentials from keyring. Silently does nothing if not found or keyring unavailable.""" + kr = _get_keyring() + if kr is None: + return + try: + kr.delete_password(SERVICE_NAME, profile) + log.debug("Cleared credentials from keyring for profile=%s", profile) + except Exception: + log.debug("Failed to clear credentials from keyring", exc_info=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index ba12458..499947f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import json import time import unittest +from pathlib import Path from unittest import mock from kognic.auth import DEFAULT_HOST @@ -1117,5 +1118,80 @@ def test_kog_uses_cached_token( ) +class CredentialsCommandTest(unittest.TestCase): + def test_load_stores_credentials(self): + import json + import tempfile + + creds = { + "clientId": "test-client-id", + "clientSecret": "test-secret", + "email": "test@kognic.com", + "userId": 1, + "issuer": "auth.kognic.test", + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(creds, f) + path = f.name + + try: + with mock.patch("kognic.auth.cli.credentials.save_credentials") as mock_save: + result = main(["credentials", "load", path]) + self.assertEqual(result, 0) + args, kwargs = mock_save.call_args + self.assertEqual(args[0].client_id, "test-client-id") + self.assertEqual(args[0].client_secret, "test-secret") + self.assertEqual(args[1], "default") + finally: + Path(path).unlink() + + def test_load_custom_profile(self): + import json + import tempfile + + creds = { + "clientId": "id", + "clientSecret": "secret", + "email": "e@kognic.com", + "userId": 1, + "issuer": "issuer", + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(creds, f) + path = f.name + + try: + with mock.patch("kognic.auth.cli.credentials.save_credentials") as mock_save: + result = main(["credentials", "load", path, "--env", "demo"]) + self.assertEqual(result, 0) + args, kwargs = mock_save.call_args + self.assertEqual(args[0].client_id, "id") + self.assertEqual(args[0].client_secret, "secret") + self.assertEqual(args[1], "demo") + finally: + Path(path).unlink() + + def test_load_missing_file_returns_error(self): + result = main(["credentials", "load", "/nonexistent/creds.json"]) + self.assertEqual(result, 1) + + def test_clear_removes_credentials(self): + with mock.patch("kognic.auth.cli.credentials.clear_credentials") as mock_clear: + result = main(["credentials", "clear"]) + self.assertEqual(result, 0) + mock_clear.assert_called_once_with("default") + + def test_clear_custom_profile(self): + with mock.patch("kognic.auth.cli.credentials.clear_credentials") as mock_clear: + result = main(["credentials", "clear", "--env", "demo"]) + self.assertEqual(result, 0) + mock_clear.assert_called_once_with("demo") + + def test_no_subcommand_prints_help(self): + with mock.patch("builtins.print"): + result = main(["credentials"]) + self.assertEqual(result, 0) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index 658f0e8..bd5cb8a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -56,6 +56,25 @@ def test_invalid_json_raises(self): load_kognic_env_config(f.name) Path(f.name).unlink() + def test_keyring_uri_not_expanded(self): + """keyring:// credentials are stored as-is, not treated as file paths.""" + data = { + "environments": { + "production": { + "host": "app.kognic.com", + "auth_server": "https://auth.app.kognic.com", + "credentials": "keyring://production", + } + } + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + config = load_kognic_env_config(f.name) + Path(f.name).unlink() + + self.assertEqual(config.environments["production"].credentials, "keyring://production") + def test_empty_contexts(self): data = {"environments": {}} with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: diff --git a/tests/test_credentials_parser.py b/tests/test_credentials_parser.py index 382ec9f..23d5c51 100644 --- a/tests/test_credentials_parser.py +++ b/tests/test_credentials_parser.py @@ -59,11 +59,32 @@ def test_parse_missing_key_raises(self): class TestGetCredentialsFromEnv(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) - def test_no_env_vars_returns_none(self): + @patch("kognic.auth.credentials_parser.credentials_store.load_credentials", return_value=None) + def test_no_env_vars_returns_none(self, _): client_id, client_secret = get_credentials_from_env() self.assertIsNone(client_id) self.assertIsNone(client_secret) + @patch.dict(os.environ, {}, clear=True) + @patch( + "kognic.auth.credentials_parser.credentials_store.load_credentials", + return_value=ApiCredentials( + client_id="kr_id", client_secret="kr_secret", email="a@b.com", user_id=1, issuer="i" + ), + ) + def test_falls_back_to_keyring(self, _): + client_id, client_secret = get_credentials_from_env() + self.assertEqual(client_id, "kr_id") + self.assertEqual(client_secret, "kr_secret") + + @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id", "KOGNIC_CLIENT_SECRET": "env_secret"}, clear=True) + @patch("kognic.auth.credentials_parser.credentials_store.load_credentials") + def test_env_vars_take_precedence_over_keyring(self, mock_load): + client_id, client_secret = get_credentials_from_env() + self.assertEqual(client_id, "env_id") + self.assertEqual(client_secret, "env_secret") + mock_load.assert_not_called() + @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id", "KOGNIC_CLIENT_SECRET": "env_secret"}, clear=True) def test_client_id_and_secret_env_vars(self): client_id, client_secret = get_credentials_from_env() @@ -174,6 +195,24 @@ def test_auth_dict(self): self.assertEqual(client_id, "test_id") self.assertEqual(client_secret, "test_secret") + @patch( + "kognic.auth.credentials_parser.credentials_store.load_credentials", + return_value=ApiCredentials( + client_id="kr_id", client_secret="kr_secret", email="a@b.com", user_id=1, issuer="i" + ), + ) + def test_auth_keyring_uri(self, mock_load): + client_id, client_secret = resolve_credentials(auth="keyring://myprofile") + self.assertEqual(client_id, "kr_id") + self.assertEqual(client_secret, "kr_secret") + mock_load.assert_called_once_with("myprofile") + + @patch("kognic.auth.credentials_parser.credentials_store.load_credentials", return_value=None) + def test_auth_keyring_uri_not_found_raises(self, _): + with self.assertRaises(ValueError) as ctx: + resolve_credentials(auth="keyring://missing-profile") + self.assertIn("missing-profile", str(ctx.exception)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_credentials_store.py b/tests/test_credentials_store.py new file mode 100644 index 0000000..e7017ab --- /dev/null +++ b/tests/test_credentials_store.py @@ -0,0 +1,135 @@ +"""Tests for the keyring-based credential store.""" + +import json +import unittest +from unittest import mock + +from kognic.auth.credentials_parser import ApiCredentials +from kognic.auth.internal.credentials_store import ( + DEFAULT_PROFILE, + SERVICE_NAME, + clear_credentials, + load_credentials, + save_credentials, +) + +FULL_CREDS_DICT = { + "clientId": "my-id", + "clientSecret": "my-secret", + "email": "test@kognic.com", + "userId": 1, + "issuer": "auth.kognic.test", +} + +FULL_CREDS = ApiCredentials( + client_id="my-id", + client_secret="my-secret", + email="test@kognic.com", + user_id=1, + issuer="auth.kognic.test", +) + + +def _mock_keyring(get_password=None, set_password=None, delete_password=None): + """Return a mock keyring module wired to the given side effects / return values.""" + kr = mock.MagicMock() + if get_password is not None: + kr.get_password.return_value = get_password + if set_password is not None: + kr.set_password.side_effect = set_password + if delete_password is not None: + kr.delete_password.side_effect = delete_password + kr.get_keyring.return_value = mock.MagicMock() # non-fail backend + return kr + + +class LoadCredentialsTest(unittest.TestCase): + def test_no_keyring_returns_none(self): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=None): + self.assertIsNone(load_credentials()) + + def test_not_stored_returns_none(self): + kr = _mock_keyring(get_password=None) + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): + self.assertIsNone(load_credentials()) + kr.get_password.assert_called_once_with(SERVICE_NAME, DEFAULT_PROFILE) + + def test_stored_credentials_returned(self): + data = json.dumps(FULL_CREDS_DICT) + kr = _mock_keyring(get_password=data) + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): + result = load_credentials() + self.assertIsInstance(result, ApiCredentials) + self.assertEqual(result.client_id, "my-id") + self.assertEqual(result.client_secret, "my-secret") + self.assertEqual(result.email, "test@kognic.com") + self.assertEqual(result.user_id, 1) + self.assertEqual(result.issuer, "auth.kognic.test") + + def test_custom_profile(self): + data = json.dumps(FULL_CREDS_DICT) + kr = _mock_keyring(get_password=data) + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): + load_credentials(profile="demo") + kr.get_password.assert_called_once_with(SERVICE_NAME, "demo") + + def test_corrupt_json_returns_none(self): + kr = _mock_keyring(get_password="not-json") + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): + self.assertIsNone(load_credentials()) + + def test_keyring_error_returns_none(self): + kr = mock.MagicMock() + kr.get_password.side_effect = Exception("keyring exploded") + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): + self.assertIsNone(load_credentials()) + + +class SaveCredentialsTest(unittest.TestCase): + def test_no_keyring_raises(self): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=None): + with self.assertRaises(RuntimeError) as ctx: + save_credentials(FULL_CREDS) + self.assertIn("keyring", str(ctx.exception).lower()) + + def test_stores_in_keyring(self): + kr = mock.MagicMock() + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): + save_credentials(FULL_CREDS) + kr.set_password.assert_called_once_with( + SERVICE_NAME, + DEFAULT_PROFILE, + json.dumps(FULL_CREDS_DICT), + ) + + def test_custom_profile(self): + kr = mock.MagicMock() + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): + save_credentials(FULL_CREDS, profile="demo") + kr.set_password.assert_called_once_with( + SERVICE_NAME, + "demo", + json.dumps(FULL_CREDS_DICT), + ) + + +class ClearCredentialsTest(unittest.TestCase): + def test_no_keyring_does_not_raise(self): + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=None): + clear_credentials() # should not raise + + def test_clears_from_keyring(self): + kr = mock.MagicMock() + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): + clear_credentials() + kr.delete_password.assert_called_once_with(SERVICE_NAME, DEFAULT_PROFILE) + + def test_error_silenced(self): + kr = mock.MagicMock() + kr.delete_password.side_effect = Exception("gone already") + with mock.patch("kognic.auth.internal.credentials_store._get_keyring", return_value=kr): + clear_credentials() # should not raise + + +if __name__ == "__main__": + unittest.main()