245 lines
8.2 KiB
Python
245 lines
8.2 KiB
Python
import httpx
|
|
import pytest
|
|
|
|
from app import docker_provider
|
|
|
|
|
|
def test_broker_mode_never_falls_back_to_a_socket_or_request_provided_host(monkeypatch) -> None:
|
|
monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker")
|
|
monkeypatch.delenv("SERVICEMAP_DOCKER_BROKER_URL", raising=False)
|
|
monkeypatch.delenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE", raising=False)
|
|
socket_used = False
|
|
|
|
def socket_client(*args, **kwargs):
|
|
nonlocal socket_used
|
|
socket_used = True
|
|
raise AssertionError("broker mode must not create a socket client")
|
|
|
|
monkeypatch.setattr(docker_provider.docker, "DockerClient", socket_client)
|
|
|
|
with pytest.raises(docker_provider.DockerProviderError, match="Broker mode requires"):
|
|
docker_provider.docker_client_for_inventory("https://attacker.invalid")
|
|
|
|
assert socket_used is False
|
|
|
|
|
|
def test_broker_destination_is_deployment_configuration_only(tmp_path, monkeypatch) -> None:
|
|
token_file = tmp_path / "broker-token"
|
|
token_file.write_text("test-token", encoding="utf-8")
|
|
monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker")
|
|
monkeypatch.setenv("SERVICEMAP_DOCKER_BROKER_URL", "https://broker.internal")
|
|
monkeypatch.setenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE", str(token_file))
|
|
|
|
client = docker_provider.docker_client_for_inventory("https://attacker.invalid")
|
|
|
|
assert isinstance(client, docker_provider.BrokerDockerClient)
|
|
assert client.base_url == "https://broker.internal"
|
|
client.close()
|
|
|
|
|
|
def _mocked_broker_client(
|
|
monkeypatch, handler, *, base_url="https://broker.internal", token="opaque-token"
|
|
):
|
|
original_client = httpx.Client
|
|
transport = httpx.MockTransport(handler)
|
|
options = {}
|
|
|
|
def client_factory(**kwargs):
|
|
options.update(kwargs)
|
|
return original_client(transport=transport, **kwargs)
|
|
|
|
monkeypatch.setattr(docker_provider.httpx, "Client", client_factory)
|
|
client = docker_provider.BrokerDockerClient(base_url, token)
|
|
return client, options
|
|
|
|
|
|
def test_inventory_uses_authenticated_fixed_get_and_health_check_has_no_token(monkeypatch) -> None:
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(request)
|
|
if request.url.path == "/healthz":
|
|
return httpx.Response(200)
|
|
return httpx.Response(200, json={"items": []})
|
|
|
|
client, options = _mocked_broker_client(monkeypatch, handler)
|
|
try:
|
|
assert client.ping() is True
|
|
assert client.containers.list() == []
|
|
finally:
|
|
client.close()
|
|
|
|
assert [(request.method, request.url.path) for request in requests] == [
|
|
("GET", "/healthz"),
|
|
("GET", "/v1/containers"),
|
|
]
|
|
assert "authorization" not in requests[0].headers
|
|
assert requests[1].headers["authorization"] == "Bearer opaque-token"
|
|
assert options["follow_redirects"] is False
|
|
assert options["trust_env"] is False
|
|
assert 0 < options["timeout"].connect <= 5
|
|
assert 0 < options["timeout"].read <= 5
|
|
|
|
|
|
def test_broker_rejects_other_authenticated_paths_without_requesting_them(monkeypatch) -> None:
|
|
requests = []
|
|
client, _ = _mocked_broker_client(
|
|
monkeypatch,
|
|
lambda request: requests.append(request) or httpx.Response(200, json={"items": []}),
|
|
)
|
|
try:
|
|
with pytest.raises(docker_provider.DockerProviderError, match="unsupported"):
|
|
client._get("/v1/containers/other")
|
|
finally:
|
|
client.close()
|
|
|
|
assert requests == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"base_url",
|
|
[
|
|
"ftp://broker.internal",
|
|
"https://user:password@broker.internal",
|
|
"https://broker.internal?token=secret",
|
|
"https://broker.internal/#fragment",
|
|
"https:///missing-host",
|
|
],
|
|
)
|
|
def test_broker_rejects_unsafe_destinations(base_url) -> None:
|
|
with pytest.raises(docker_provider.DockerProviderError):
|
|
docker_provider.BrokerDockerClient(base_url, "opaque-token")
|
|
|
|
|
|
def test_broker_rejects_header_injection_in_token() -> None:
|
|
with pytest.raises(docker_provider.DockerProviderError):
|
|
docker_provider.BrokerDockerClient("https://broker.internal", "token\r\nX-Leak: yes")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{},
|
|
{"items": "not-a-list"},
|
|
{"items": [{"containerRef": "one"}, "malformed-row"]},
|
|
{"items": [{"containerRef": "one", "name": "one", "state": "running"}]},
|
|
],
|
|
)
|
|
def test_broker_rejects_partial_or_malformed_inventory(payload, monkeypatch) -> None:
|
|
client, _ = _mocked_broker_client(
|
|
monkeypatch,
|
|
lambda request: httpx.Response(200, json=payload),
|
|
)
|
|
try:
|
|
with pytest.raises(docker_provider.DockerProviderError, match="invalid"):
|
|
client.containers.list()
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def test_broker_rejects_non_json_inventory_response(monkeypatch) -> None:
|
|
client, _ = _mocked_broker_client(
|
|
monkeypatch,
|
|
lambda request: httpx.Response(
|
|
200, content=b'{"items":[]}', headers={"content-type": "text/plain"}
|
|
),
|
|
)
|
|
try:
|
|
with pytest.raises(docker_provider.DockerProviderError, match="invalid"):
|
|
client.containers.list()
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def test_broker_rejects_oversized_inventory_response(monkeypatch) -> None:
|
|
monkeypatch.setattr(docker_provider, "_BROKER_MAX_RESPONSE_BYTES", 4)
|
|
client, _ = _mocked_broker_client(
|
|
monkeypatch,
|
|
lambda request: httpx.Response(200, content=b'{"items":[]}'),
|
|
)
|
|
try:
|
|
with pytest.raises(docker_provider.DockerProviderError, match="invalid"):
|
|
client.containers.list()
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def test_broker_container_preserves_discovery_fields_without_exposing_environment(
|
|
monkeypatch,
|
|
) -> None:
|
|
item = {
|
|
"containerRef": "abcdef1234567890",
|
|
"name": "/example-service",
|
|
"image": "example/service:1.0",
|
|
"state": "running",
|
|
"health": "healthy",
|
|
"healthcheckPresent": True,
|
|
"labels": {"servicemap.enabled": "true"},
|
|
"networks": ["default"],
|
|
"ports": {
|
|
"8000/tcp": [{"hostIp": "0.0.0.0", "hostPort": "1253"}],
|
|
"5432/tcp": None,
|
|
},
|
|
}
|
|
client, _ = _mocked_broker_client(
|
|
monkeypatch,
|
|
lambda request: httpx.Response(200, json={"items": [item]}),
|
|
)
|
|
try:
|
|
[container] = client.containers.list()
|
|
finally:
|
|
client.close()
|
|
|
|
assert container.id == item["containerRef"]
|
|
assert container.name == "example-service"
|
|
assert container.image.tags == [item["image"]]
|
|
assert container.attrs["State"] == {"Status": "running", "Health": {"Status": "healthy"}}
|
|
assert container.attrs["Config"]["Labels"] == item["labels"]
|
|
assert container.attrs["Config"]["Healthcheck"] == {"BrokerReported": True}
|
|
assert container.attrs["Config"]["Env"] == []
|
|
assert container.attrs["NetworkSettings"]["Networks"] == {"default": {}}
|
|
assert container.attrs["NetworkSettings"]["Ports"] == {
|
|
"8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "1253"}],
|
|
"5432/tcp": [],
|
|
}
|
|
|
|
|
|
def test_broker_health_status_proves_healthcheck_when_old_schema_omits_flag(monkeypatch) -> None:
|
|
item = {
|
|
"containerRef": "abcdef1234567890",
|
|
"name": "example-service",
|
|
"image": "example/service:1.0",
|
|
"state": "running",
|
|
"health": "healthy",
|
|
"labels": {},
|
|
"networks": [],
|
|
"ports": {},
|
|
}
|
|
client, _ = _mocked_broker_client(
|
|
monkeypatch, lambda request: httpx.Response(200, json={"items": [item]})
|
|
)
|
|
try:
|
|
[container] = client.containers.list()
|
|
finally:
|
|
client.close()
|
|
|
|
assert container.attrs["Config"]["Healthcheck"] == {"BrokerReported": True}
|
|
|
|
|
|
def test_broker_error_messages_do_not_expose_destination_or_credential(monkeypatch) -> None:
|
|
def handler(request):
|
|
raise httpx.ConnectError(
|
|
"failed with opaque-token at https://broker.internal", request=request
|
|
)
|
|
|
|
client, _ = _mocked_broker_client(monkeypatch, handler)
|
|
try:
|
|
with pytest.raises(docker_provider.DockerProviderError) as caught:
|
|
client.containers.list()
|
|
finally:
|
|
client.close()
|
|
|
|
assert "Docker broker request failed" in str(caught.value)
|
|
assert "opaque-token" not in str(caught.value)
|
|
assert "broker.internal" not in str(caught.value)
|