Skip to content

Commit d5357ab

Browse files
node9aiclaude
andcommitted
test(saas): fix HTTPError mock, assert Bearer token, add run_id test; trim docstring
- Fix urllib.error.HTTPError mock: hdrs=None → http.client.HTTPMessage() (prevents AttributeError on Python versions that read headers from error) - test_saas_route_taken_and_bearer_token_sent: assert Authorization header contains the correct Bearer token (was only checking routing, not auth) - test_saas_run_id_forwarded: verify run_id reaches the SaaS payload - docstring: remove hardcoded 5s/65s values, reference constants by name only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2bd6400 commit d5357ab

2 files changed

Lines changed: 38 additions & 10 deletions

File tree

‎node9/_client.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,8 @@ def evaluate(tool_name: str, args: dict[str, Any], *, run_id: str = "") -> None:
288288
- Daemon timeout / connection closed → ActionDeniedException (fail-closed)
289289
- SaaS HTTP error or timeout → RuntimeError (propagates to caller)
290290
291-
Timeouts (see _CHECK_TIMEOUT / _WAIT_TIMEOUT module constants):
292-
- Initial connection: 5 s
293-
- Waiting for human decision: 65 s — daemon auto-denies at ~55 s
291+
Timeouts: see _CHECK_TIMEOUT (initial connection) and _WAIT_TIMEOUT
292+
(human decision wait) module constants for current values.
294293
295294
⚠️ evaluate() is a low-level primitive. Unlike @protect it does NOT run DLP
296295
scanning or inject agent context (run_id, agent_name) automatically. Use

‎tests/test_client.py‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,9 @@ def test_offline_with_require_approval_policy_warns(self, monkeypatch, tmp_path)
336336
class TestSaaSRoute:
337337
"""Tests for the NODE9_API_KEY → SaaS routing path."""
338338

339-
def test_saas_http_error_raises_runtime_error(self, monkeypatch, tmp_path):
339+
def test_saas_http_error_raises_runtime_error(self, monkeypatch):
340340
"""SaaS HTTP errors (e.g. 401, 500) must propagate as RuntimeError, not auto-approve."""
341+
import http.client
341342
import urllib.error
342343
monkeypatch.setenv("NODE9_API_KEY", "test-key")
343344
monkeypatch.setenv("NODE9_API_URL", "https://api.node9.ai/api/v1/intercept")
@@ -346,7 +347,7 @@ def test_saas_http_error_raises_runtime_error(self, monkeypatch, tmp_path):
346347
url="https://api.node9.ai/api/v1/intercept",
347348
code=401,
348349
msg="Unauthorized",
349-
hdrs=None, # type: ignore[arg-type]
350+
hdrs=http.client.HTTPMessage(),
350351
fp=None,
351352
)
352353
with patch("urllib.request.urlopen", side_effect=http_error):
@@ -363,17 +364,45 @@ def test_saas_url_error_raises_runtime_error(self, monkeypatch):
363364
with pytest.raises(RuntimeError, match="Failed to reach node9 SaaS"):
364365
evaluate("bash", {"command": "ls"})
365366

366-
def test_saas_route_taken_when_api_key_set(self, monkeypatch):
367-
"""When NODE9_API_KEY is set the SaaS path is taken regardless of daemon state."""
368-
monkeypatch.setenv("NODE9_API_KEY", "test-key")
367+
def test_saas_route_taken_and_bearer_token_sent(self, monkeypatch):
368+
"""When NODE9_API_KEY is set: SaaS path is taken and Bearer token is in the request."""
369+
monkeypatch.setenv("NODE9_API_KEY", "test-key-abc")
369370
monkeypatch.setenv("NODE9_API_URL", "https://api.node9.ai/api/v1/intercept")
370371

372+
captured_headers: list[dict] = []
371373
approve_resp = _make_response({"approved": True})
372-
with patch("urllib.request.urlopen", return_value=approve_resp):
374+
375+
def capturing_urlopen(req, timeout):
376+
captured_headers.append(dict(req.headers))
377+
return approve_resp
378+
379+
with patch("urllib.request.urlopen", side_effect=capturing_urlopen):
373380
with patch("node9._client._daemon_reachable") as mock_check:
374381
evaluate("bash", {"command": "ls"})
375-
# daemon reachability must NOT be checked when API key is present
382+
376383
mock_check.assert_not_called()
384+
assert captured_headers, "urlopen was never called"
385+
auth = captured_headers[0].get("Authorization", "")
386+
assert auth == "Bearer test-key-abc", f"Expected Bearer token, got: {auth!r}"
387+
388+
def test_saas_run_id_forwarded(self, monkeypatch):
389+
"""run_id is included in the SaaS request payload."""
390+
monkeypatch.setenv("NODE9_API_KEY", "test-key")
391+
monkeypatch.setenv("NODE9_API_URL", "https://api.node9.ai/api/v1/intercept")
392+
393+
sent: list[dict] = []
394+
approve_resp = _make_response({"approved": True})
395+
396+
def capturing_urlopen(req, timeout):
397+
if hasattr(req, "data") and req.data:
398+
sent.append(json.loads(req.data))
399+
return approve_resp
400+
401+
with patch("urllib.request.urlopen", side_effect=capturing_urlopen):
402+
evaluate("bash", {"command": "ls"}, run_id="saas-run-xyz")
403+
404+
assert sent, "No request was sent"
405+
assert sent[0].get("runId") == "saas-run-xyz"
377406

378407

379408
class TestConfigure:

0 commit comments

Comments
 (0)