Files
echo-core/tests/test_secrets.py
MoltBot Service 85c72e4b3d rename secrets.py to credential_store.py, enhance /status, add usage tracking
- Rename src/secrets.py → src/credential_store.py (avoid stdlib conflict)
- Enhanced /status command: uptime, tokens, cost, context window usage
- Session metadata now tracks input/output tokens, cost, duration
- _safe_env() changed from allowlist to blocklist approach
- Better Claude CLI error logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:54:59 +00:00

258 lines
8.4 KiB
Python

"""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