Files
bot-bottle/tests/unit/test_egress_proxy_apply.py
T
didericis 1cec0d9aa6
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 1m7s
feat(egress-proxy-apply): mirror new route hosts into pipelock allowlist
When the operator approves an egress-proxy-block proposal that
adds a host to egress-proxy's routes, the request would still 403
downstream at pipelock — pipelock's hostname allowlist is set at
bottle launch and doesn't learn about routes added later. The
agent saw "Approved" but the very next retry still failed.

Fix: `apply_routes_change` now mirrors every host in the proposed
routes onto pipelock's allowlist before flipping egress-proxy.
Order matters — pipelock first so a pipelock failure doesn't
leave egress-proxy in a half-state:

  1. Validate the new routes content.
  2. Extract the hosts.
  3. Merge them onto pipelock's current allowlist
     (`apply_allowlist_change` — restarts pipelock with the merged
     yaml). No-op when every host is already present.
  4. docker cp the new routes.yaml into egress-proxy + SIGHUP.

If pipelock's restart fails, egress-proxy is untouched and the
operator gets a clear error pointing at the pipelock half-state.
If egress-proxy's update fails after pipelock succeeded, pipelock
just has the host pre-allowlisted — harmless extra-permissive
until the operator retries.

Adds `_hosts_in_routes` helper using the addon's own parser
(so the mirrored host set matches exactly what the addon will
match on). 4 new unit tests; 368 total pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:34:10 -04:00

88 lines
3.0 KiB
Python

"""Unit: validate_routes_content (PRD 0014 retargeted by PRD 0017
chunk 3). docker exec / cp / kill paths are covered by the
integration test."""
import unittest
from claude_bottle.backend.docker.egress_proxy_apply import (
EgressProxyApplyError,
_hosts_in_routes,
validate_routes_content,
)
class TestValidateRoutesContent(unittest.TestCase):
def test_accepts_minimal_route_table(self):
validate_routes_content('{"routes": []}')
validate_routes_content(
'{"routes": [{"host": "api.github.com"}]}'
)
def test_accepts_full_route(self):
validate_routes_content(
'{"routes": [{"host": "api.github.com",'
' "path_allowlist": ["/repos/x/"],'
' "auth_scheme": "Bearer",'
' "token_env": "EGRESS_PROXY_TOKEN_0"}]}'
)
def test_rejects_bad_json(self):
with self.assertRaises(EgressProxyApplyError) as cm:
validate_routes_content("{not json")
self.assertIn("not valid", str(cm.exception))
def test_rejects_non_object_top_level(self):
with self.assertRaises(EgressProxyApplyError):
validate_routes_content("[]")
def test_rejects_missing_routes_key(self):
with self.assertRaises(EgressProxyApplyError):
validate_routes_content('{"other": []}')
def test_rejects_non_list_routes(self):
with self.assertRaises(EgressProxyApplyError):
validate_routes_content('{"routes": "not a list"}')
def test_rejects_partial_auth_pair(self):
# The addon-core parser enforces both-or-neither — the apply
# path picks this up before SIGHUP'ing the sidecar.
with self.assertRaises(EgressProxyApplyError):
validate_routes_content(
'{"routes": [{"host": "x.example",'
' "auth_scheme": "Bearer"}]}'
)
class TestHostsInRoutes(unittest.TestCase):
def test_extracts_each_unique_host(self):
hosts = _hosts_in_routes(
'{"routes": [{"host": "api.github.com"},'
' {"host": "github.com"},'
' {"host": "api.anthropic.com"}]}'
)
# Sorted+deduped.
self.assertEqual(
["api.anthropic.com", "api.github.com", "github.com"],
hosts,
)
def test_dedupes_same_host(self):
hosts = _hosts_in_routes(
'{"routes": [{"host": "x.example", "path_allowlist": ["/a/"]},'
' {"host": "x.example", "path_allowlist": ["/b/"]}]}'
)
self.assertEqual(["x.example"], hosts)
def test_empty_routes_returns_empty(self):
self.assertEqual([], _hosts_in_routes('{"routes": []}'))
def test_invalid_routes_raises(self):
# The mirror helper relies on parsing succeeding; bad input
# should error before pipelock is touched.
with self.assertRaises(EgressProxyApplyError):
_hosts_in_routes('{"routes": [{"path": "/no-host/"}]}')
if __name__ == "__main__":
unittest.main()