|
| 1 | +"""Tests for secret redaction and API key validation in the weather service. |
| 2 | +
|
| 3 | +Loads agent.py and configuration.py in isolation (same approach as |
| 4 | +test_weather_service.py) to avoid pulling in heavy deps like opentelemetry. |
| 5 | +""" |
| 6 | + |
| 7 | +import importlib.util |
| 8 | +import logging |
| 9 | +import pathlib |
| 10 | +import sys |
| 11 | +from types import ModuleType |
| 12 | +from unittest.mock import MagicMock |
| 13 | + |
| 14 | +# --- Isolation setup (must happen before any weather_service imports) --- |
| 15 | +_fake_ws = ModuleType("weather_service") |
| 16 | +_fake_ws.__path__ = [] # type: ignore[attr-defined] |
| 17 | +sys.modules.setdefault("weather_service", _fake_ws) |
| 18 | +sys.modules.setdefault("weather_service.observability", MagicMock()) |
| 19 | + |
| 20 | +_BASE = pathlib.Path(__file__).parent.parent.parent / "a2a" / "weather_service" / "src" / "weather_service" |
| 21 | + |
| 22 | + |
| 23 | +def _load_module(name: str, path: pathlib.Path): |
| 24 | + spec = importlib.util.spec_from_file_location(name, path) |
| 25 | + mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] |
| 26 | + sys.modules[name] = mod |
| 27 | + spec.loader.exec_module(mod) # type: ignore[union-attr] |
| 28 | + return mod |
| 29 | + |
| 30 | + |
| 31 | +_config_mod = _load_module("weather_service.configuration", _BASE / "configuration.py") |
| 32 | + |
| 33 | +# Mock modules that agent.py imports but we don't need |
| 34 | +for mod_name in [ |
| 35 | + "uvicorn", |
| 36 | + "langchain_core", |
| 37 | + "langchain_core.messages", |
| 38 | + "starlette", |
| 39 | + "starlette.middleware", |
| 40 | + "starlette.middleware.base", |
| 41 | + "starlette.routing", |
| 42 | + "a2a", |
| 43 | + "a2a.server", |
| 44 | + "a2a.server.agent_execution", |
| 45 | + "a2a.server.apps", |
| 46 | + "a2a.server.events", |
| 47 | + "a2a.server.events.event_queue", |
| 48 | + "a2a.server.request_handlers", |
| 49 | + "a2a.server.tasks", |
| 50 | + "a2a.types", |
| 51 | + "a2a.utils", |
| 52 | + "weather_service.graph", |
| 53 | +]: |
| 54 | + sys.modules.setdefault(mod_name, MagicMock()) |
| 55 | + |
| 56 | +_agent_mod = _load_module("weather_service.agent", _BASE / "agent.py") |
| 57 | + |
| 58 | +Configuration = _config_mod.Configuration |
| 59 | +SecretRedactionFilter = _agent_mod.SecretRedactionFilter |
| 60 | + |
| 61 | + |
| 62 | +# --- Tests --- |
| 63 | + |
| 64 | + |
| 65 | +class TestSecretRedactionFilter: |
| 66 | + """Test the logging filter that redacts Bearer tokens and API keys.""" |
| 67 | + |
| 68 | + def setup_method(self): |
| 69 | + self.filt = SecretRedactionFilter() |
| 70 | + |
| 71 | + def _make_record(self, msg: str, args=None) -> logging.LogRecord: |
| 72 | + return logging.LogRecord( |
| 73 | + name="test", |
| 74 | + level=logging.INFO, |
| 75 | + pathname="", |
| 76 | + lineno=0, |
| 77 | + msg=msg, |
| 78 | + args=args, |
| 79 | + exc_info=None, |
| 80 | + ) |
| 81 | + |
| 82 | + def test_redacts_bearer_token(self): |
| 83 | + record = self._make_record("Authorization: Bearer sk-abc123xyz789secret") |
| 84 | + self.filt.filter(record) |
| 85 | + assert "sk-abc123xyz789secret" not in record.msg |
| 86 | + assert "[REDACTED]" in record.msg |
| 87 | + |
| 88 | + def test_bearer_case_insensitive(self): |
| 89 | + record = self._make_record("header: bearer my-secret-token-value") |
| 90 | + self.filt.filter(record) |
| 91 | + assert "my-secret-token-value" not in record.msg |
| 92 | + |
| 93 | + def test_preserves_non_secret_messages(self): |
| 94 | + record = self._make_record("Processing weather request for New York") |
| 95 | + self.filt.filter(record) |
| 96 | + assert record.msg == "Processing weather request for New York" |
| 97 | + |
| 98 | + def test_redacts_bearer_in_tuple_args(self): |
| 99 | + record = self._make_record("Header: %s", ("Bearer secret123",)) |
| 100 | + self.filt.filter(record) |
| 101 | + assert "secret123" not in record.args[0] |
| 102 | + |
| 103 | + def test_redacts_bearer_in_dict_args(self): |
| 104 | + record = self._make_record("%(auth)s") |
| 105 | + record.args = {"auth": "Bearer secret123"} |
| 106 | + self.filt.filter(record) |
| 107 | + assert "secret123" not in record.args["auth"] |
| 108 | + |
| 109 | + def test_always_returns_true(self): |
| 110 | + record = self._make_record("Bearer secret123") |
| 111 | + assert self.filt.filter(record) is True |
| 112 | + |
| 113 | + def test_redacts_literal_configured_key(self, monkeypatch): |
| 114 | + rhoai_key = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" |
| 115 | + monkeypatch.setenv("LLM_API_KEY", rhoai_key) |
| 116 | + filt = SecretRedactionFilter() |
| 117 | + record = self._make_record(f"Sending request with api-key={rhoai_key}") |
| 118 | + filt.filter(record) |
| 119 | + assert rhoai_key not in record.msg |
| 120 | + assert "[REDACTED]" in record.msg |
| 121 | + |
| 122 | + def test_literal_key_redaction_in_args(self, monkeypatch): |
| 123 | + rhoai_key = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" |
| 124 | + monkeypatch.setenv("LLM_API_KEY", rhoai_key) |
| 125 | + filt = SecretRedactionFilter() |
| 126 | + record = self._make_record("key=%s", (rhoai_key,)) |
| 127 | + filt.filter(record) |
| 128 | + assert rhoai_key not in record.args[0] |
| 129 | + |
| 130 | + def test_short_key_not_redacted(self, monkeypatch): |
| 131 | + monkeypatch.setenv("LLM_API_KEY", "dummy") |
| 132 | + filt = SecretRedactionFilter() |
| 133 | + record = self._make_record("Using dummy config for testing dummy values") |
| 134 | + filt.filter(record) |
| 135 | + assert "dummy" in record.msg |
| 136 | + |
| 137 | + def test_no_crash_when_key_unset(self, monkeypatch): |
| 138 | + monkeypatch.delenv("LLM_API_KEY", raising=False) |
| 139 | + filt = SecretRedactionFilter() |
| 140 | + record = self._make_record("Normal log message") |
| 141 | + filt.filter(record) |
| 142 | + assert record.msg == "Normal log message" |
| 143 | + |
| 144 | + |
| 145 | +class TestConfigurationApiKeyValidation: |
| 146 | + """Test API key validation logic.""" |
| 147 | + |
| 148 | + def test_dummy_key_with_remote_api_is_invalid(self, monkeypatch): |
| 149 | + monkeypatch.setenv("LLM_API_BASE", "https://api.openai.com/v1") |
| 150 | + monkeypatch.setenv("LLM_API_KEY", "dummy") |
| 151 | + assert Configuration().has_valid_api_key is False |
| 152 | + |
| 153 | + def test_dummy_key_with_localhost_is_valid(self): |
| 154 | + config = Configuration() # defaults: localhost + dummy |
| 155 | + assert config.has_valid_api_key is True |
| 156 | + |
| 157 | + def test_empty_key_with_remote_api_is_invalid(self, monkeypatch): |
| 158 | + monkeypatch.setenv("LLM_API_BASE", "https://api.openai.com/v1") |
| 159 | + monkeypatch.setenv("LLM_API_KEY", "") |
| 160 | + assert Configuration().has_valid_api_key is False |
| 161 | + |
| 162 | + def test_placeholder_keys_with_remote_are_invalid(self, monkeypatch): |
| 163 | + monkeypatch.setenv("LLM_API_BASE", "https://api.openai.com/v1") |
| 164 | + for key in ["changeme", "your-api-key-here"]: |
| 165 | + monkeypatch.setenv("LLM_API_KEY", key) |
| 166 | + assert Configuration().has_valid_api_key is False, f"'{key}' should be invalid" |
| 167 | + |
| 168 | + def test_placeholder_keys_with_local_llm_are_valid(self, monkeypatch): |
| 169 | + monkeypatch.setenv("LLM_API_BASE", "http://localhost:11434/v1") |
| 170 | + for key in ["dummy", "changeme", ""]: |
| 171 | + monkeypatch.setenv("LLM_API_KEY", key) |
| 172 | + assert Configuration().has_valid_api_key is True |
| 173 | + |
| 174 | + def test_real_key_is_valid(self, monkeypatch): |
| 175 | + monkeypatch.setenv("LLM_API_BASE", "https://api.openai.com/v1") |
| 176 | + monkeypatch.setenv("LLM_API_KEY", "sk-proj-realkey123") |
| 177 | + assert Configuration().has_valid_api_key is True |
| 178 | + |
| 179 | + def test_rhoai_maas_key_is_valid(self, monkeypatch): |
| 180 | + monkeypatch.setenv( |
| 181 | + "LLM_API_BASE", |
| 182 | + "https://model--maas-apicast-production.apps.prod.rhoai.rh-aiservices-bu.com:443/v1", |
| 183 | + ) |
| 184 | + monkeypatch.setenv("LLM_API_KEY", "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6") |
| 185 | + assert Configuration().has_valid_api_key is True |
| 186 | + |
| 187 | + def test_127_is_local(self, monkeypatch): |
| 188 | + monkeypatch.setenv("LLM_API_BASE", "http://127.0.0.1:8080/v1") |
| 189 | + assert Configuration().has_valid_api_key is True |
0 commit comments