Skip to content

Commit 8f95122

Browse files
add tests for secrets resolver logic
1 parent ac794d8 commit 8f95122

4 files changed

Lines changed: 325 additions & 0 deletions

File tree

tests/resolvers/__init__.py

Whitespace-only changes.

tests/resolvers/test_kubernetes.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Kubernetes secret resolver tests.
2+
3+
The kubernetes client is mocked, so these run whether or not the optional
4+
``kubernetes`` extra is installed.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import base64
10+
import sys
11+
from unittest.mock import MagicMock
12+
13+
import pytest
14+
15+
from agentevals.resolvers.kubernetes import KubernetesSecretResolver, create_kubernetes_resolver
16+
17+
18+
def _client_returning(data: dict[str, str]) -> MagicMock:
19+
client = MagicMock()
20+
client.read_namespaced_secret.return_value = MagicMock(data=data)
21+
return client
22+
23+
24+
def _b64(value: str) -> str:
25+
return base64.b64encode(value.encode()).decode()
26+
27+
28+
class TestResolve:
29+
async def test_reads_and_base64_decodes_value(self):
30+
client = _client_returning({"api-key": _b64("sk-secret-value")})
31+
resolver = KubernetesSecretResolver(client)
32+
33+
value = await resolver.resolve({"namespace": "ns", "name": "creds", "key": "api-key"})
34+
35+
assert value == "sk-secret-value"
36+
client.read_namespaced_secret.assert_called_once_with("creds", "ns")
37+
38+
async def test_missing_fields_raise(self):
39+
resolver = KubernetesSecretResolver(MagicMock())
40+
with pytest.raises(ValueError, match="namespace, name, key"):
41+
await resolver.resolve({"kind": "kubernetes"})
42+
43+
async def test_key_not_found_lists_names_not_values(self):
44+
secret_value = _b64("sk-do-not-leak")
45+
client = _client_returning({"api-key": secret_value, "tls.crt": _b64("cert")})
46+
resolver = KubernetesSecretResolver(client)
47+
48+
with pytest.raises(ValueError) as exc:
49+
await resolver.resolve({"namespace": "ns", "name": "creds", "key": "wrong"})
50+
51+
message = str(exc.value)
52+
assert "api-key" in message and "tls.crt" in message
53+
# The enumeration must never echo the base64-encoded secret values.
54+
assert secret_value not in message
55+
56+
57+
class TestFactory:
58+
def test_requires_kubernetes_extra(self, monkeypatch):
59+
# Shadow the kubernetes package so the lazy import fails regardless of
60+
# whether the extra is installed in the test venv.
61+
monkeypatch.setitem(sys.modules, "kubernetes", None)
62+
with pytest.raises(RuntimeError, match="kubernetes"):
63+
create_kubernetes_resolver({"kind": "kubernetes"})

tests/resolvers/test_registry.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Secret resolver framework tests.
2+
3+
Covers entry-point discovery and the builtins < entry points < programmatic
4+
precedence chain (mirroring tests/run/test_sinks.py), the built-in env resolver,
5+
the per-run ContextVar carrier, and resolve_credential_refs.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
from unittest.mock import MagicMock, patch
12+
13+
import pytest
14+
15+
from agentevals.resolvers import (
16+
EnvSecretResolver,
17+
build_resolver,
18+
clear_resolver_plugin_registry,
19+
create_env_resolver,
20+
get_resolved_credential,
21+
register_resolver_factory,
22+
registered_resolver_kinds,
23+
reset_resolved_credentials,
24+
resolve_credential_refs,
25+
set_resolved_credentials,
26+
)
27+
28+
29+
@pytest.fixture
30+
def isolated_resolver_plugins():
31+
"""``register_resolver_factory`` is process-global; reset around plugin tests."""
32+
clear_resolver_plugin_registry()
33+
yield
34+
clear_resolver_plugin_registry()
35+
36+
37+
class TestEnvResolver:
38+
def test_env_resolver_discovered_via_entry_point(self):
39+
assert "env" in registered_resolver_kinds()
40+
41+
def test_create_env_resolver_returns_env_resolver(self):
42+
assert isinstance(create_env_resolver({"kind": "env"}), EnvSecretResolver)
43+
44+
async def test_resolves_from_environment(self, monkeypatch):
45+
monkeypatch.setenv("AE_TEST_SECRET", "sk-from-env")
46+
ref = {"kind": "env", "name": "AE_TEST_SECRET"}
47+
assert await build_resolver(ref).resolve(ref) == "sk-from-env"
48+
49+
async def test_missing_name_field_raises(self):
50+
with pytest.raises(ValueError, match="name"):
51+
await EnvSecretResolver().resolve({"kind": "env"})
52+
53+
async def test_unset_variable_raises(self, monkeypatch):
54+
monkeypatch.delenv("AE_TEST_MISSING", raising=False)
55+
with pytest.raises(ValueError, match="not set"):
56+
await EnvSecretResolver().resolve({"kind": "env", "name": "AE_TEST_MISSING"})
57+
58+
59+
class TestBuildResolver:
60+
def test_missing_kind_raises(self):
61+
with pytest.raises(ValueError, match="missing a 'kind'"):
62+
build_resolver({"name": "X"})
63+
64+
def test_unknown_kind_raises_and_lists_available(self):
65+
with pytest.raises(ValueError, match="unknown secret resolver kind 'nope'") as exc:
66+
build_resolver({"kind": "nope"})
67+
assert "env" in str(exc.value)
68+
69+
70+
class TestPluginPrecedence:
71+
async def test_programmatic_registration_overrides_entry_point(self, isolated_resolver_plugins):
72+
class Sentinel:
73+
async def resolve(self, ref):
74+
return "programmatic"
75+
76+
register_resolver_factory("env", lambda _ref: Sentinel())
77+
resolver = build_resolver({"kind": "env"})
78+
assert isinstance(resolver, Sentinel)
79+
assert await resolver.resolve({"kind": "env"}) == "programmatic"
80+
81+
def test_register_adds_new_kind(self, isolated_resolver_plugins):
82+
register_resolver_factory("custom", lambda _ref: MagicMock())
83+
assert "custom" in registered_resolver_kinds()
84+
85+
86+
class TestEntryPointDiscovery:
87+
"""Entry-point discovery without relying on packages installed in the test venv."""
88+
89+
async def test_entry_point_resolver_resolves_kind(self, isolated_resolver_plugins):
90+
class FromEp:
91+
async def resolve(self, ref):
92+
return "from-entry-point"
93+
94+
def factory(_ref):
95+
return FromEp()
96+
97+
ep = MagicMock()
98+
ep.name = "from_ep"
99+
ep.load.return_value = factory
100+
101+
with patch("agentevals.resolvers.entry_points", return_value=[ep]):
102+
resolver = build_resolver({"kind": "from_ep"})
103+
ep.load.assert_called_once_with()
104+
assert await resolver.resolve({"kind": "from_ep"}) == "from-entry-point"
105+
106+
def test_entry_point_load_failure_skipped(self, isolated_resolver_plugins, caplog):
107+
ep = MagicMock()
108+
ep.name = "broken_pkg_resolver"
109+
ep.load.side_effect = ImportError("dist not installed")
110+
111+
with patch("agentevals.resolvers.entry_points", return_value=[ep]), caplog.at_level(logging.ERROR):
112+
kinds = registered_resolver_kinds()
113+
assert "broken_pkg_resolver" not in kinds
114+
assert any("failed to load resolver entry point" in r.getMessage() for r in caplog.records)
115+
116+
def test_non_callable_entry_point_skipped(self, isolated_resolver_plugins, caplog):
117+
ep = MagicMock()
118+
ep.name = "bad_export"
119+
ep.load.return_value = "not_callable"
120+
121+
with patch("agentevals.resolvers.entry_points", return_value=[ep]), caplog.at_level(logging.WARNING):
122+
kinds = registered_resolver_kinds()
123+
assert "bad_export" not in kinds
124+
assert any("not callable" in r.getMessage() for r in caplog.records)
125+
126+
127+
class TestResolvedCredentialContext:
128+
def test_get_returns_none_for_absent_key(self):
129+
token = set_resolved_credentials({"present": "v"})
130+
try:
131+
assert get_resolved_credential("present") == "v"
132+
assert get_resolved_credential("absent") is None
133+
finally:
134+
reset_resolved_credentials(token)
135+
136+
async def test_resolve_credential_refs_maps_logical_names_to_values(self, monkeypatch):
137+
monkeypatch.setenv("AE_TEST_A", "val-a")
138+
monkeypatch.setenv("AE_TEST_B", "val-b")
139+
resolved = await resolve_credential_refs(
140+
{
141+
"a": {"kind": "env", "name": "AE_TEST_A"},
142+
"b": {"kind": "env", "name": "AE_TEST_B"},
143+
}
144+
)
145+
assert resolved == {"a": "val-a", "b": "val-b"}

tests/test_credential_injection.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Per-evaluation judge credential injection tests.
2+
3+
Exercises the agentevals construction path (build_eval_metric -> get_evaluator ->
4+
_inject_judge_credential) without making any network calls, the fail-closed
5+
behavior on an unresolved credential, the concurrency isolation that motivates the
6+
ContextVar design, and a guard test that fails loudly if the ADK judge seam moves.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import logging
13+
import os
14+
15+
from agentevals.builtin_metrics import (
16+
_inject_judge_credential,
17+
build_eval_metric,
18+
evaluate_builtin_metric,
19+
get_evaluator,
20+
)
21+
from agentevals.resolvers import (
22+
get_resolved_credential,
23+
reset_resolved_credentials,
24+
set_resolved_credentials,
25+
)
26+
27+
28+
def _judge_evaluator(model_id: str = "openai/gpt-4o"):
29+
return get_evaluator(build_eval_metric("final_response_match_v2", model_id, 0.5))
30+
31+
32+
class TestInjection:
33+
def test_litellm_judge_uses_injected_key_not_env(self, monkeypatch):
34+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
35+
evaluator = _judge_evaluator()
36+
37+
_inject_judge_credential(evaluator, "sk-injected", base_url="https://proxy.local")
38+
39+
args = evaluator._judge_model._additional_args
40+
assert args["api_key"] == "sk-injected"
41+
assert args["base_url"] == "https://proxy.local"
42+
43+
def test_gemini_judge_client_replaced(self):
44+
evaluator = get_evaluator(
45+
build_eval_metric("rubric_based_final_response_quality_v1", "gemini-2.5-flash", 0.5, rubrics=["good?"])
46+
)
47+
# Seeded only by injection: an un-injected judge builds its client lazily on
48+
# first access, so the cache slot is absent until we replace it.
49+
assert "api_client" not in evaluator._judge_model.__dict__
50+
51+
_inject_judge_credential(evaluator, "gem-injected")
52+
53+
assert "api_client" in evaluator._judge_model.__dict__
54+
55+
def test_inject_is_noop_on_non_judge_evaluator(self, caplog):
56+
evaluator = get_evaluator(build_eval_metric("tool_trajectory_avg_score", None, 0.5))
57+
with caplog.at_level(logging.WARNING):
58+
_inject_judge_credential(evaluator, "sk-x")
59+
assert not hasattr(evaluator, "_judge_model")
60+
assert any("not judge-backed" in r.getMessage() for r in caplog.records)
61+
62+
63+
class TestFailClosed:
64+
async def test_unresolved_credential_errors_instead_of_ambient_auth(self):
65+
result = await evaluate_builtin_metric(
66+
metric_name="final_response_match_v2",
67+
actual_invocations=[],
68+
expected_invocations=[object()],
69+
judge_model="openai/gpt-4o",
70+
threshold=0.5,
71+
credential_ref="judge-openai",
72+
)
73+
assert result.score is None
74+
assert "judge-openai" in result.error
75+
assert "credentialRefs" in result.error
76+
77+
78+
class TestConcurrencyIsolation:
79+
async def test_concurrent_runs_do_not_cross_talk(self, monkeypatch):
80+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
81+
env_before = dict(os.environ)
82+
83+
async def run_one(key: str) -> str:
84+
set_resolved_credentials({"judge": key})
85+
# Yield after setting so the sibling task sets its own value before we
86+
# read back. With a shared global instead of a ContextVar, both tasks
87+
# would observe the last writer's key and this assertion would fail.
88+
await asyncio.sleep(0)
89+
evaluator = _judge_evaluator()
90+
_inject_judge_credential(evaluator, get_resolved_credential("judge"))
91+
return evaluator._judge_model._additional_args["api_key"]
92+
93+
first, second = await asyncio.gather(run_one("sk-AAA"), run_one("sk-BBB"))
94+
95+
assert first == "sk-AAA"
96+
assert second == "sk-BBB"
97+
assert dict(os.environ) == env_before
98+
99+
100+
class TestAdkSeamGuard:
101+
"""Fails loudly if the version-pinned ADK judge seam moves."""
102+
103+
def test_llm_as_judge_exposes_setup_auto_rater(self):
104+
from google.adk.evaluation.llm_as_judge import LlmAsJudge
105+
106+
assert hasattr(LlmAsJudge, "_setup_auto_rater")
107+
108+
def test_judge_evaluator_exposes_seam_attributes(self):
109+
evaluator = _judge_evaluator()
110+
assert hasattr(evaluator, "_judge_model")
111+
assert hasattr(evaluator, "_judge_model_options")
112+
assert evaluator._judge_model_options.judge_model == "openai/gpt-4o"
113+
114+
def test_litellm_forwards_api_key_kwarg(self):
115+
from google.adk.models.lite_llm import LiteLlm
116+
117+
assert LiteLlm(model="openai/gpt-4o", api_key="sentinel")._additional_args["api_key"] == "sentinel"

0 commit comments

Comments
 (0)