Skip to content

Commit cb9f647

Browse files
AbirAbbasclaude
andcommitted
test(hitl): 17 unit tests for the environment-scout substrate
Three pillars covered: - services.py — KNOWN_SERVICES inventory bounds, missing-path safety, file + directory signal detection, prompt-summary rendering. - credentials_store.py — round-trip, blank/None filtering, isolation between execution_ids, get-returns-copy, concurrent thread safety, inject-into-env layering rules. - scout closure round-trip — pass 1 emits ask_user_form via the wrapper, pass 2 sees prior_user_responses and returns scoped_credentials; no-services-detected short-circuits the pause; model_dump(exclude={"scoped_credentials"}) actually strips the field. All tests mock HaxClient + app.pause; no real network, no real harness. Pin a baseline of 8+ services so future trimming is visible in diff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b99dc9 commit cb9f647

1 file changed

Lines changed: 304 additions & 0 deletions

File tree

tests/test_environment_scout.py

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
"""Unit tests for the swe_af.hitl environment-scout substrate.
2+
3+
Three pillars covered:
4+
5+
1. ``swe_af.hitl.services`` — static signal-file detection.
6+
2. ``swe_af.hitl.credentials_store`` — process-local store with isolation
7+
and thread safety.
8+
3. ``swe_af.hitl.scout_schema`` + the wrapper loop — the LLM closure's
9+
pass-1/pass-2 round-trip through ``run_with_ask_user``.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import threading
15+
from unittest.mock import AsyncMock, MagicMock
16+
17+
import pytest
18+
19+
from swe_af.hitl.ask_user import AskUserForm, AskUserFormField
20+
from swe_af.hitl.credentials_store import (
21+
_STORE,
22+
clear_scoped_credentials,
23+
get_scoped_credentials,
24+
inject_credentials_into_env,
25+
store_scoped_credentials,
26+
)
27+
from swe_af.hitl.scout_schema import ScoutResult
28+
from swe_af.hitl.services import (
29+
KNOWN_SERVICES,
30+
ServiceCredentialSpec,
31+
detect_services_from_repo,
32+
known_service_summary_for_prompt,
33+
)
34+
from swe_af.hitl.wrapper import AskUserBudget, run_with_ask_user
35+
36+
37+
# ---------------------------------------------------------------------------
38+
# services.py — detection
39+
# ---------------------------------------------------------------------------
40+
41+
42+
def test_known_services_inventory_covers_baseline():
43+
"""We promised at least 8 services in the spec — pin that here."""
44+
assert len(KNOWN_SERVICES) >= 8
45+
names = {s.service_name for s in KNOWN_SERVICES}
46+
assert {"Railway", "Fly.io", "Vercel", "Supabase", "Sentry"}.issubset(names)
47+
48+
49+
def test_detect_services_returns_empty_for_missing_path():
50+
assert detect_services_from_repo("") == []
51+
assert detect_services_from_repo("/this/path/does/not/exist") == []
52+
53+
54+
def test_detect_services_finds_railway_and_sentry(tmp_path):
55+
(tmp_path / "railway.toml").write_text("[deploy]\n")
56+
(tmp_path / "sentry.properties").write_text("dsn=foo\n")
57+
found = detect_services_from_repo(str(tmp_path))
58+
names = [s.service_name for s in found]
59+
assert "Railway" in names
60+
assert "Sentry" in names
61+
62+
63+
def test_detect_services_signal_can_be_directory(tmp_path):
64+
"""A signal file may actually be a directory (e.g. supabase/migrations)."""
65+
(tmp_path / "supabase").mkdir()
66+
(tmp_path / "supabase" / "migrations").mkdir()
67+
found = detect_services_from_repo(str(tmp_path))
68+
assert "Supabase" in [s.service_name for s in found]
69+
70+
71+
def test_known_service_summary_for_prompt_is_markdown_bullets():
72+
out = known_service_summary_for_prompt(KNOWN_SERVICES[:2])
73+
assert out.startswith("- **")
74+
assert "env `" in out
75+
assert "mint at " in out
76+
77+
78+
# ---------------------------------------------------------------------------
79+
# credentials_store.py — round-trip, filtering, isolation, thread safety
80+
# ---------------------------------------------------------------------------
81+
82+
83+
@pytest.fixture(autouse=True)
84+
def _wipe_store():
85+
"""Each test starts and ends with an empty store."""
86+
_STORE.clear()
87+
yield
88+
_STORE.clear()
89+
90+
91+
def test_store_and_get_round_trip():
92+
store_scoped_credentials("build-A", {"RAILWAY_TOKEN": "secret"})
93+
assert get_scoped_credentials("build-A") == {"RAILWAY_TOKEN": "secret"}
94+
95+
96+
def test_store_filters_blank_and_none_values():
97+
store_scoped_credentials(
98+
"build-A",
99+
{"RAILWAY_TOKEN": "ok", "EMPTY": "", "WHITESPACE": " ", "NONE": None}, # type: ignore[dict-item]
100+
)
101+
got = get_scoped_credentials("build-A")
102+
assert got == {"RAILWAY_TOKEN": "ok"}
103+
104+
105+
def test_store_isolation_between_builds():
106+
store_scoped_credentials("build-A", {"RAILWAY_TOKEN": "a"})
107+
store_scoped_credentials("build-B", {"RAILWAY_TOKEN": "b"})
108+
assert get_scoped_credentials("build-A") == {"RAILWAY_TOKEN": "a"}
109+
assert get_scoped_credentials("build-B") == {"RAILWAY_TOKEN": "b"}
110+
111+
112+
def test_clear_only_removes_the_specified_build():
113+
store_scoped_credentials("build-A", {"RAILWAY_TOKEN": "a"})
114+
store_scoped_credentials("build-B", {"RAILWAY_TOKEN": "b"})
115+
clear_scoped_credentials("build-A")
116+
assert get_scoped_credentials("build-A") == {}
117+
assert get_scoped_credentials("build-B") == {"RAILWAY_TOKEN": "b"}
118+
119+
120+
def test_get_returns_copy_not_reference():
121+
"""Mutating the returned dict must not affect the stored value."""
122+
store_scoped_credentials("build-A", {"RAILWAY_TOKEN": "a"})
123+
got = get_scoped_credentials("build-A")
124+
got["RAILWAY_TOKEN"] = "tampered"
125+
got["NEW_VAR"] = "injected"
126+
assert get_scoped_credentials("build-A") == {"RAILWAY_TOKEN": "a"}
127+
128+
129+
def test_concurrent_writes_isolate_by_execution_id():
130+
"""Two threads writing under different keys must not race each other."""
131+
def writer(name: str, value: str):
132+
for _ in range(100):
133+
store_scoped_credentials(name, {"TOKEN": value})
134+
135+
t1 = threading.Thread(target=writer, args=("build-A", "a"))
136+
t2 = threading.Thread(target=writer, args=("build-B", "b"))
137+
t1.start()
138+
t2.start()
139+
t1.join()
140+
t2.join()
141+
142+
assert get_scoped_credentials("build-A") == {"TOKEN": "a"}
143+
assert get_scoped_credentials("build-B") == {"TOKEN": "b"}
144+
145+
146+
def test_inject_credentials_returns_new_dict():
147+
base = {"PATH": "/usr/bin", "RAILWAY_TOKEN": "stale"}
148+
store_scoped_credentials("build-A", {"RAILWAY_TOKEN": "fresh"})
149+
merged = inject_credentials_into_env(base, "build-A")
150+
assert merged == {"PATH": "/usr/bin", "RAILWAY_TOKEN": "fresh"}
151+
# base must be untouched.
152+
assert base == {"PATH": "/usr/bin", "RAILWAY_TOKEN": "stale"}
153+
154+
155+
def test_inject_credentials_no_scope_returns_base_only():
156+
base = {"PATH": "/usr/bin"}
157+
merged = inject_credentials_into_env(base, "")
158+
assert merged == base
159+
assert merged is not base # still a copy
160+
161+
162+
def test_inject_credentials_empty_base_works():
163+
store_scoped_credentials("build-A", {"RAILWAY_TOKEN": "x"})
164+
merged = inject_credentials_into_env(None, "build-A")
165+
assert merged == {"RAILWAY_TOKEN": "x"}
166+
167+
168+
# ---------------------------------------------------------------------------
169+
# scout_schema.py + wrapper closure round-trip
170+
# ---------------------------------------------------------------------------
171+
172+
173+
def _scout_result_pass1(spec_form: AskUserForm) -> ScoutResult:
174+
return ScoutResult(
175+
detected_services=[
176+
ServiceCredentialSpec(
177+
service_name="Railway",
178+
env_var_name="RAILWAY_TOKEN",
179+
mint_url="https://example",
180+
permissions_hint="hint",
181+
signal_files=["railway.toml"],
182+
)
183+
],
184+
scoped_credentials={},
185+
skipped_services=[],
186+
ask_user_form=spec_form,
187+
)
188+
189+
190+
def _scout_result_pass2(values: dict[str, str]) -> ScoutResult:
191+
return ScoutResult(
192+
detected_services=[],
193+
scoped_credentials=values,
194+
skipped_services=[],
195+
summary=f"Got {len(values)} credential(s).",
196+
ask_user_form=None,
197+
)
198+
199+
200+
def _approval_result(values: dict[str, str]):
201+
obj = MagicMock()
202+
obj.decision = "approved"
203+
obj.feedback = ""
204+
obj.raw_response = {"values": values}
205+
return obj
206+
207+
208+
def _silent_app():
209+
app = MagicMock()
210+
app.note = MagicMock()
211+
app.pause = AsyncMock()
212+
return app
213+
214+
215+
@pytest.mark.asyncio
216+
async def test_scout_closure_pass1_emits_form_pass2_emits_credentials():
217+
"""Two-pass dance: scout asks once, gets the answers, returns the dict."""
218+
form = AskUserForm(
219+
title="Pick credentials",
220+
fields=[
221+
AskUserFormField(
222+
id="RAILWAY_TOKEN",
223+
type="input",
224+
label="Railway token",
225+
required=False,
226+
),
227+
],
228+
)
229+
reasoner = AsyncMock(
230+
side_effect=[
231+
_scout_result_pass1(form),
232+
_scout_result_pass2({"RAILWAY_TOKEN": "rt_xxx"}),
233+
]
234+
)
235+
hax = MagicMock()
236+
hax.create_request = MagicMock(return_value=MagicMock(id="r1", url="u"))
237+
app = _silent_app()
238+
app.pause.return_value = _approval_result({"RAILWAY_TOKEN": "rt_xxx"})
239+
240+
parsed = await run_with_ask_user(
241+
reasoner_fn=reasoner,
242+
reasoner_kwargs={"prior_user_responses": []},
243+
app=app,
244+
hax_client=hax,
245+
budget=AskUserBudget(remaining=3),
246+
)
247+
248+
assert isinstance(parsed, ScoutResult)
249+
assert parsed.scoped_credentials == {"RAILWAY_TOKEN": "rt_xxx"}
250+
assert parsed.ask_user_form is None
251+
assert reasoner.await_count == 2
252+
253+
# The second invocation should have received the prior values.
254+
second_call_kwargs = reasoner.await_args_list[1].kwargs
255+
prior = second_call_kwargs["prior_user_responses"]
256+
assert len(prior) == 1
257+
assert prior[0]["values"] == {"RAILWAY_TOKEN": "rt_xxx"}
258+
259+
260+
@pytest.mark.asyncio
261+
async def test_scout_closure_skips_pause_when_no_services_detected():
262+
"""If the LLM judges no credentials needed, the wrapper short-circuits."""
263+
reasoner = AsyncMock(
264+
return_value=ScoutResult(
265+
detected_services=[],
266+
ask_user_form=None,
267+
summary="No third-party credentials needed.",
268+
)
269+
)
270+
app = _silent_app()
271+
272+
parsed = await run_with_ask_user(
273+
reasoner_fn=reasoner,
274+
reasoner_kwargs={"prior_user_responses": []},
275+
app=app,
276+
hax_client=MagicMock(),
277+
budget=AskUserBudget(remaining=3),
278+
)
279+
280+
assert parsed.scoped_credentials == {}
281+
reasoner.assert_awaited_once()
282+
app.pause.assert_not_called()
283+
284+
285+
# ---------------------------------------------------------------------------
286+
# Schema serialisation — scoped_credentials must NEVER leak through model_dump
287+
# when excluded.
288+
# ---------------------------------------------------------------------------
289+
290+
291+
def test_scout_result_model_dump_can_exclude_scoped_credentials():
292+
"""We exclude the field at the reasoner boundary so it never reaches the
293+
control-plane workflow_execution row."""
294+
r = ScoutResult(
295+
detected_services=[],
296+
scoped_credentials={"RAILWAY_TOKEN": "secret-do-not-log"},
297+
summary="ok",
298+
)
299+
safe = r.model_dump(exclude={"scoped_credentials"})
300+
assert "scoped_credentials" not in safe
301+
assert safe["summary"] == "ok"
302+
# The full dump still has it (caller's choice).
303+
full = r.model_dump()
304+
assert full["scoped_credentials"] == {"RAILWAY_TOKEN": "secret-do-not-log"}

0 commit comments

Comments
 (0)