test(orchestrator): live Docker coverage for the control-plane auth fix (#400)
lint / lint (push) Successful in 2m30s
test / unit (pull_request) Successful in 1m24s
test / integration (pull_request) Failing after 3m8s
test / coverage (pull_request) Failing after 1m59s

ca91fc4 fixed the control plane's missing caller authentication but only
verified it end-to-end on Apple Container; Docker had the same code change
(docker_cmd.py's env injection) backed only by the in-process dispatch()
unit tests, which never exercise the real HTTP server or a real container.

Ran it for real first: brought up the actual orchestrator + gateway via
Docker, hit the published control-plane port directly. Confirms /health
stays open, /bottles and /resolve both 401 with no token or a wrong one
(the enumeration and credential-lift vectors from #400), and /bottles
succeeds with the real per-host secret.

Added as a proper integration test so this stays covered: a subclass of
OrchestratorService giving the gateway its own unique name too (the base
class only parameterizes the orchestrator's), plus a throwaway
BOT_BOTTLE_ROOT and a random port, so a run can never collide with a real
host's orchestrator or gateway. Gated on a reachable Docker daemon, same
as the existing docker gateway/broker integration tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 16:17:42 -04:00
parent ca91fc4d91
commit 4f36b919b2
@@ -0,0 +1,110 @@
"""Integration: the Docker orchestrator's control plane enforces the
per-host auth secret against a real container (issue #400).
Unit tests exercise `dispatch()` in-process, socket-free. This starts the
actual orchestrator + gateway as Docker containers and drives the real HTTP
server over its published loopback port, the same path an agent sharing the
gateway network — or the trusted host CLI — would use.
Gated on a reachable Docker daemon. Uses unique container/network names and
a throwaway `BOT_BOTTLE_ROOT` so it never collides with a real per-host
orchestrator or gateway.
"""
from __future__ import annotations
import json
import secrets
import tempfile
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from bot_bottle.orchestrator.control_plane import CONTROL_AUTH_HEADER
from bot_bottle.orchestrator.gateway import DockerGateway
from bot_bottle.orchestrator.lifecycle import OrchestratorService
from bot_bottle.paths import host_control_plane_token
from tests._docker import skip_unless_docker
class _IsolatedOrchestratorService(OrchestratorService):
"""`OrchestratorService`, but with a uniquely-named gateway too (the base
class only parameterizes the orchestrator's own name/network/port) so a
test run can never touch a real host's orchestrator or gateway."""
def __init__(self, *, gateway_name: str, **kwargs: object) -> None:
super().__init__(**kwargs) # type: ignore[arg-type]
self._test_gateway_name = gateway_name
def _gateway(self) -> DockerGateway:
return DockerGateway(
self._gateway_image,
name=self._test_gateway_name,
network=self.network,
orchestrator_url=self.internal_url,
)
@skip_unless_docker()
class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
def setUp(self) -> None:
suffix = secrets.token_hex(4)
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.svc = _IsolatedOrchestratorService(
orchestrator_name=f"bot-bottle-orch-itest-{suffix}",
gateway_name=f"bot-bottle-gw-itest-{suffix}",
network=f"bot-bottle-net-itest-{suffix}",
port=20000 + secrets.randbelow(10000),
host_root=Path(self._tmp.name),
)
self.addCleanup(self.svc.stop)
self.svc.ensure_running()
self.token = host_control_plane_token()
def _request(
self, method: str, path: str, *, token: str | None = None
) -> tuple[int, dict[str, object]]:
headers = {CONTROL_AUTH_HEADER: token} if token is not None else {}
req = urllib.request.Request(
self.svc.url + path, method=method, headers=headers
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def test_health_is_open_without_a_token(self) -> None:
status, payload = self._request("GET", "/health")
self.assertEqual(200, status)
self.assertEqual("ok", payload["status"])
def test_bottles_rejects_a_caller_with_no_token(self) -> None:
"""The enumeration attack from issue #400: an agent sharing the
gateway network could list every bottle + its policy with no
credential at all."""
status, _ = self._request("GET", "/bottles")
self.assertEqual(401, status)
def test_bottles_rejects_a_wrong_token(self) -> None:
status, _ = self._request("GET", "/bottles", token="not-the-real-secret")
self.assertEqual(401, status)
def test_bottles_accepts_the_real_token(self) -> None:
status, payload = self._request("GET", "/bottles", token=self.token)
self.assertEqual(200, status)
self.assertEqual([], payload["bottles"])
def test_resolve_rejects_a_caller_with_no_token(self) -> None:
"""The credential-lift attack from issue #400: an agent could POST
/resolve directly and read back the upstream tokens it's never meant
to see."""
status, _ = self._request("POST", "/resolve")
self.assertEqual(401, status)
if __name__ == "__main__":
unittest.main()