"""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, test-only image tags (never the production `:latest` ones, so a rebuild here can't make `_running_image_is_current()` see a real host's running gateway as stale and force-recreate it), and a throwaway `BOT_BOTTLE_ROOT` so a run never touches or collides with a real per-host orchestrator or gateway. The whole stack is brought up once for the class (`setUpClass`), not per test method — every test here is a read-only check against the same running control plane. """ from __future__ import annotations import os import secrets import subprocess import tempfile import unittest from pathlib import Path from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint from bot_bottle.orchestrator.client import OrchestratorClient from bot_bottle.backend.docker.infra import DockerInfraService from bot_bottle.paths import host_orchestrator_token from tests._docker import skip_unless_docker # Fixed (not per-run-suffixed) so repeated runs reuse the same layer-cached # image instead of leaking a new dangling tag on every invocation. _TEST_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:itest" _TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest" _TEST_INFRA_IMAGE = "bot-bottle-infra:itest" @skip_unless_docker() @unittest.skipIf( os.environ.get("GITEA_ACTIONS") == "true", "skipped under act_runner: the orchestrator container bind-mounts the repo " "path into a container on the socket-shared host daemon, which can't see the " "runner's /workspace — same host-bind-mount constraint as the other " "bottle-bringup integration tests", ) class TestDockerOrchestratorAuthIntegration(unittest.TestCase): @classmethod def setUpClass(cls) -> None: suffix = secrets.token_hex(4) cls._tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with cls.addClassCleanup(cls._tmp.cleanup) # host_orchestrator_token() — both the token read below and the one # DockerInfraService injects into the container's env — resolves its # path via the *ambient* BOT_BOTTLE_ROOT env var, not the host_root # kwarg passed to the constructor (that kwarg only controls the DB # bind-mount destination). Without pointing the env var at the same # throwaway dir, this "isolated" test would read/write the developer's # real ~/.bot-bottle/orchestrator-token. previous_root = os.environ.get("BOT_BOTTLE_ROOT") def _restore_root() -> None: if previous_root is None: os.environ.pop("BOT_BOTTLE_ROOT", None) else: os.environ["BOT_BOTTLE_ROOT"] = previous_root os.environ["BOT_BOTTLE_ROOT"] = cls._tmp.name cls.addClassCleanup(_restore_root) infra_name = f"bot-bottle-infra-itest-{suffix}" network = f"bot-bottle-net-itest-{suffix}" host_root = Path(cls._tmp.name) cls.addClassCleanup( cls._teardown_docker, infra_name, network, host_root ) cls.svc = DockerInfraService( infra_name=infra_name, network=network, image=_TEST_INFRA_IMAGE, port=20000 + secrets.randbelow(10000), host_root=host_root, ) cls.svc.ensure_running() # The control plane now verifies role-scoped signed tokens, not the raw # key. Mint one of each role from the host signing key (issue #469 review). signing_key = host_orchestrator_token() cls.cli_token = mint(ROLE_CLI, signing_key) cls.gateway_token = mint(ROLE_GATEWAY, signing_key) @staticmethod def _teardown_docker( infra_name: str, network: str, host_root: Path ) -> None: subprocess.run( ["docker", "rm", "--force", infra_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) subprocess.run( ["docker", "network", "rm", network], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) # The infra container (no USER directive) wrote the registry # DB as root into the throwaway host_root; chown it back so the # (non-root) tempdir cleanup can remove it. Same workaround # test_multitenant_isolation.py uses for the identical bind mount. subprocess.run( ["docker", "run", "--rm", "-v", f"{host_root}:/r", "--entrypoint", "chown", _TEST_INFRA_IMAGE, "-R", f"{os.getuid()}:{os.getgid()}", "/r"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) def _request( self, method: str, path: str, *, token: str = "" ) -> tuple[int, dict[str, object]]: # Reuses the real host-side client's request/response handling rather # than hand-rolling urllib here; _request (not one of the named # wrapper methods) is what exposes raw status codes for arbitrary # paths/tokens, which is exactly what these auth-boundary tests need. client = OrchestratorClient(self.svc.url, auth_token=token) return client._request(method, path) # pylint: disable=protected-access 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_cli_token(self) -> None: status, payload = self._request("GET", "/bottles", token=self.cli_token) self.assertEqual(200, status) self.assertEqual([], payload["bottles"]) def test_bottles_rejects_the_gateway_token(self) -> None: """A compromised gateway holds only a `gateway` token — it must not be able to enumerate bottles (an operator route) with it (issue #469).""" status, _ = self._request("GET", "/bottles", token=self.gateway_token) self.assertEqual(403, status) 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) def test_resolve_accepts_the_gateway_token(self) -> None: """The gateway's own token DOES reach /resolve: with an empty body it gets 400 (missing source_ip) rather than the 401 a rejected caller sees — proving the role gate let the gateway token through.""" status, _ = self._request("POST", "/resolve", token=self.gateway_token) self.assertEqual(400, status) # past the role gate; body validation fails if __name__ == "__main__": unittest.main()