"""Comprehensive tests for src/secrets.py and cli.py secrets subcommand.""" import json import pytest from unittest.mock import patch from pathlib import Path from src.credential_store import ( SERVICE, REQUIRED_SECRETS, set_secret, get_secret, get_json_secret, delete_secret, list_secrets, check_secrets, _get_registry, _save_registry, ) # --------------------------------------------------------------------------- # Helper: a fake keyring store backed by a plain dict # --------------------------------------------------------------------------- class FakeKeyring: """In-memory keyring replacement for tests.""" def __init__(self): self.store: dict[tuple[str, str], str] = {} def get_password(self, service, name): return self.store.get((service, name)) def set_password(self, service, name, value): self.store[(service, name)] = value def delete_password(self, service, name): key = (service, name) if key not in self.store: import keyring.errors raise keyring.errors.PasswordDeleteError(name) del self.store[key] @pytest.fixture(autouse=True) def mock_keyring(): """Patch keyring globally for every test so the real keyring is never touched.""" fake = FakeKeyring() with ( patch("src.credential_store.keyring.get_password", side_effect=fake.get_password), patch("src.credential_store.keyring.set_password", side_effect=fake.set_password), patch("src.credential_store.keyring.delete_password", side_effect=fake.delete_password), ): yield fake # ===== _get_registry / _save_registry ====================================== class TestRegistry: def test_get_registry_empty(self, mock_keyring): assert _get_registry() == [] def test_save_and_get_registry(self, mock_keyring): _save_registry(["alpha", "beta"]) result = _get_registry() assert result == ["alpha", "beta"] def test_save_registry_deduplicates_and_sorts(self, mock_keyring): _save_registry(["zeta", "alpha", "zeta"]) result = _get_registry() assert result == ["alpha", "zeta"] # ===== set_secret ========================================================== class TestSetSecret: def test_stores_value(self, mock_keyring): set_secret("mykey", "myvalue") assert mock_keyring.store[(SERVICE, "mykey")] == "myvalue" def test_updates_registry(self, mock_keyring): set_secret("mykey", "myvalue") assert "mykey" in _get_registry() def test_set_multiple_secrets(self, mock_keyring): set_secret("a", "1") set_secret("b", "2") reg = _get_registry() assert "a" in reg assert "b" in reg def test_set_same_secret_twice_no_duplicate_registry(self, mock_keyring): set_secret("dup", "v1") set_secret("dup", "v2") assert _get_registry().count("dup") == 1 assert mock_keyring.store[(SERVICE, "dup")] == "v2" # ===== get_secret ========================================================== class TestGetSecret: def test_returns_value(self, mock_keyring): set_secret("k", "v") assert get_secret("k") == "v" def test_returns_none_when_missing(self, mock_keyring): assert get_secret("nonexistent") is None # ===== get_json_secret ===================================================== class TestGetJsonSecret: def test_returns_deserialized_dict(self, mock_keyring): payload = {"host": "localhost", "port": 5432} set_secret("db", json.dumps(payload)) assert get_json_secret("db") == payload def test_returns_none_when_missing(self, mock_keyring): assert get_json_secret("nope") is None def test_raises_on_invalid_json(self, mock_keyring): set_secret("bad", "not-json{{{") with pytest.raises(json.JSONDecodeError): get_json_secret("bad") # ===== delete_secret ======================================================= class TestDeleteSecret: def test_delete_existing_returns_true(self, mock_keyring): set_secret("rm_me", "val") assert delete_secret("rm_me") is True def test_delete_existing_removes_from_keyring(self, mock_keyring): set_secret("rm_me", "val") delete_secret("rm_me") assert (SERVICE, "rm_me") not in mock_keyring.store def test_delete_existing_removes_from_registry(self, mock_keyring): set_secret("rm_me", "val") delete_secret("rm_me") assert "rm_me" not in _get_registry() def test_delete_nonexistent_returns_false(self, mock_keyring): assert delete_secret("ghost") is False # ===== list_secrets ======================================================== class TestListSecrets: def test_empty_when_nothing_stored(self, mock_keyring): assert list_secrets() == [] def test_returns_names(self, mock_keyring): set_secret("x", "1") set_secret("y", "2") names = list_secrets() assert "x" in names assert "y" in names # ===== check_secrets ======================================================== class TestCheckSecrets: def test_missing_required(self, mock_keyring): result = check_secrets() assert result == {"discord_token": False} def test_present_required(self, mock_keyring): set_secret("discord_token", "tok-123") result = check_secrets() assert result == {"discord_token": True} # =========================================================================== # CLI tests — exercise cmd_secrets via main() with mocked sys.argv # =========================================================================== import sys from cli import main as cli_main class TestCLISecretsList: def test_list_empty(self, mock_keyring, capsys): with patch("sys.argv", ["echo", "secrets", "list"]): cli_main() assert "No secrets stored" in capsys.readouterr().out def test_list_populated(self, mock_keyring, capsys): set_secret("foo", "bar") with patch("sys.argv", ["echo", "secrets", "list"]): cli_main() assert "foo" in capsys.readouterr().out class TestCLISecretsSet: def test_set_from_file(self, mock_keyring, tmp_path, capsys): secret_file = tmp_path / "token.txt" secret_file.write_text("super-secret\n") with patch("sys.argv", ["echo", "secrets", "set", "my_token", "--file", str(secret_file)]): cli_main() out = capsys.readouterr().out assert "set from file" in out assert get_secret("my_token") == "super-secret" # file should be deleted after set assert not secret_file.exists() def test_set_interactive(self, mock_keyring, capsys): with ( patch("sys.argv", ["echo", "secrets", "set", "my_token"]), patch("cli.getpass.getpass", return_value="interactive-val"), ): cli_main() out = capsys.readouterr().out assert "set" in out.lower() assert get_secret("my_token") == "interactive-val" def test_set_from_missing_file_exits(self, mock_keyring, tmp_path): with ( patch("sys.argv", ["echo", "secrets", "set", "x", "--file", str(tmp_path / "nope.txt")]), pytest.raises(SystemExit) as exc_info, ): cli_main() assert exc_info.value.code == 1 class TestCLISecretsDelete: def test_delete_existing(self, mock_keyring, capsys): set_secret("d", "val") with patch("sys.argv", ["echo", "secrets", "delete", "d"]): cli_main() assert "deleted" in capsys.readouterr().out def test_delete_nonexistent(self, mock_keyring, capsys): with patch("sys.argv", ["echo", "secrets", "delete", "nope"]): cli_main() assert "not found" in capsys.readouterr().out class TestCLISecretsTest: def test_missing_required_exits_1(self, mock_keyring, capsys): with ( patch("sys.argv", ["echo", "secrets", "test"]), pytest.raises(SystemExit) as exc_info, ): cli_main() assert exc_info.value.code == 1 assert "MISSING" in capsys.readouterr().out def test_all_present(self, mock_keyring, capsys): set_secret("discord_token", "tok") with patch("sys.argv", ["echo", "secrets", "test"]): cli_main() out = capsys.readouterr().out assert "OK" in out assert "All required secrets present" in out