atlas-iac/testing/tests/test_hermes_deadline_http.py

320 lines
10 KiB
Python
Raw Permalink Normal View History

"""Absolute-deadline contracts for the killable HTTP control helper."""
from __future__ import annotations
import base64
import io
import json
import subprocess
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from testing.tests.test_hermes_scm_broker_support import _load
class _Handler(BaseHTTPRequestHandler):
def _respond(self):
if self.path == "/slow":
time.sleep(10)
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "http://127.0.0.1:1/evil")
self.send_header("Content-Length", "0")
self.end_headers()
return
status = 404 if self.path == "/missing" else 200
body = b"x" * 64 if self.path == "/large" else b'{"ok":true}'
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
do_GET = _respond
do_POST = _respond
def log_message(self, *_args):
return
@pytest.fixture(scope="module")
def server():
value = HTTPServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=value.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{value.server_port}"
value.shutdown()
def _control(url: str, **overrides):
value = {
"url": url,
"method": "GET",
"headers": {"Accept": "application/json"},
"body": "",
"maximum": 1024,
"timeout": 10,
}
value.update(overrides)
return value
def test_child_exchange_round_trips_and_reports_http_errors(server):
module = _load("deadline_http")
result = module._child_exchange(_control(server + "/ok"))
assert (result.status, result.content_type) == (200, "application/json")
assert result.body == b'{"ok":true}'
posted = module._child_exchange(
_control(
server + "/ok",
method="POST",
body=base64.b64encode(b'{"a":1}').decode(),
)
)
assert posted.status == 200
missing = module._child_exchange(_control(server + "/missing"))
assert missing.status == 404
def test_child_exchange_rejects_redirects_and_oversized_responses(server):
module = _load("deadline_http")
with pytest.raises(Exception):
module._child_exchange(_control(server + "/redirect"))
with pytest.raises(ValueError, match="too large"):
module._child_exchange(_control(server + "/large", maximum=8))
@pytest.mark.parametrize(
"overrides",
[
{"url": 1},
{"method": None},
{"headers": ["not", "dict"]},
{"headers": {"Accept": 5}},
{"body": b"raw"},
{"maximum": "big"},
{"maximum": -1},
{"maximum": 4 * 1024 * 1024},
{"timeout": "soon"},
{"timeout": 0},
{"timeout": 121},
],
)
def test_child_exchange_rejects_invalid_control_documents(overrides):
module = _load("deadline_http")
control = _control("http://127.0.0.1:1/")
control.update(overrides)
with pytest.raises(ValueError, match="invalid control"):
module._child_exchange(control)
def test_child_exchange_rejects_bad_or_oversized_encoded_bodies():
module = _load("deadline_http")
with pytest.raises(ValueError):
module._child_exchange(_control("http://127.0.0.1:1/", body="!!not-b64!!"))
huge = base64.b64encode(b"x" * (module.MAX_CONTROL_BYTES + 1)).decode()
with pytest.raises(ValueError, match="request too large"):
module._child_exchange(_control("http://127.0.0.1:1/", body=huge))
def test_read_response_enforces_deadline_and_size():
module = _load("deadline_http")
class Reader:
def __init__(self, body: bytes):
self.stream = io.BytesIO(body)
def read(self, limit: int):
return self.stream.read(limit)
with pytest.raises(TimeoutError):
module._read_response(Reader(b"abc"), 10, time.monotonic() - 1)
with pytest.raises(ValueError, match="too large"):
module._read_response(Reader(b"abcdef"), 4, time.monotonic() + 5)
value = module._read_response(Reader(b"abc"), 10, time.monotonic() + 5)
assert value == b"abc"
def _run_child_main(module, monkeypatch, raw: bytes) -> dict:
stdin = type("Stdin", (), {"buffer": io.BytesIO(raw)})()
stdout = io.StringIO()
monkeypatch.setattr(module.sys, "stdin", stdin)
monkeypatch.setattr(module.sys, "stdout", stdout)
code = module._child_main()
return code, json.loads(stdout.getvalue())
def test_child_main_reports_success_and_failure_documents(monkeypatch, server):
module = _load("deadline_http")
code, output = _run_child_main(
module, monkeypatch, json.dumps(_control(server + "/ok")).encode()
)
assert code == 0 and output["ok"] is True
assert base64.b64decode(output["body"]) == b'{"ok":true}'
for raw in (
b"x" * (module.MAX_CONTROL_BYTES + 1),
b"not json",
b"[1,2]",
):
code, output = _run_child_main(module, monkeypatch, raw)
assert code == 1 and output == {"ok": False}
class _FakeProcess:
def __init__(self, output: bytes, returncode: int = 0):
self.args = ["fake-child"]
self.pid = 0
self.returncode = returncode
self.output = output
self.stdin_payload = None
def communicate(self, payload=None, timeout=None):
self.stdin_payload = payload
return self.output, b""
def _fake_popen(output: bytes, returncode: int = 0):
def popen(*_args, **_kwargs):
return _FakeProcess(output, returncode)
return popen
def _success_output(body: bytes, **overrides) -> bytes:
value = {
"ok": True,
"status": 200,
"content_type": "application/json",
"body": base64.b64encode(body).decode("ascii"),
}
value.update(overrides)
return json.dumps(value).encode()
def test_exchange_validates_bounds_and_request_body():
module = _load("deadline_http")
request = urllib.request.Request("https://scm.bstein.dev/api/v1/x")
with pytest.raises(module.PolicyError, match="bounds are invalid"):
module.exchange(request, maximum=1, timeout=0)
with pytest.raises(module.PolicyError, match="bounds are invalid"):
module.exchange(request, maximum=-1, timeout=10)
request.data = "not-bytes"
with pytest.raises(module.PolicyError, match="safe size"):
module.exchange(request, maximum=1, timeout=10)
request.data = b"x" * (module.MAX_CONTROL_BYTES + 1)
with pytest.raises(module.PolicyError, match="safe size"):
module.exchange(request, maximum=1, timeout=10)
def test_exchange_returns_result_and_enforces_response_limit():
module = _load("deadline_http")
request = urllib.request.Request(
"https://scm.bstein.dev/api/v1/x", data=b"{}", method="POST"
)
result = module.exchange(
request, maximum=64, timeout=10, popen=_fake_popen(_success_output(b"body"))
)
assert (result.status, result.content_type, result.body) == (
200,
"application/json",
b"body",
)
with pytest.raises(module.PolicyError, match="safe size"):
module.exchange(
request,
maximum=2,
timeout=10,
popen=_fake_popen(_success_output(b"body")),
)
@pytest.mark.parametrize(
("output", "returncode", "match"),
[
(b"", 1, "request failed"),
(b"x" * (4 * 1024 * 1024 + 1), 0, "request failed"),
(b"not json", 0, "invalid evidence"),
(b'{"ok":false}', 0, "invalid evidence"),
(b"[1]", 0, "invalid evidence"),
(b'{"ok":true}', 0, "invalid evidence"),
(b'{"ok":true,"status":"200","content_type":"a","body":""}', 0, "invalid evidence"),
(b'{"ok":true,"status":200,"content_type":"a","body":"!!"}', 0, "invalid evidence"),
],
ids=[
"empty-error",
"oversized-output",
"invalid-json",
"false-result",
"array-result",
"missing-fields",
"empty-body",
"invalid-body",
],
)
def test_exchange_rejects_invalid_helper_evidence(output, returncode, match):
module = _load("deadline_http")
request = urllib.request.Request("https://scm.bstein.dev/api/v1/x")
with pytest.raises(module.PolicyError, match=match):
module.exchange(
request, maximum=64, timeout=10, popen=_fake_popen(output, returncode)
)
def test_exchange_kills_the_helper_when_the_deadline_expires(server):
module = _load("deadline_http")
request = urllib.request.Request(server + "/slow")
started = time.monotonic()
with pytest.raises(module.PolicyError, match="deadline exceeded"):
module.exchange(request, maximum=64, timeout=1.0)
assert time.monotonic() - started < 5
def test_exchange_fails_before_spawn_wait_when_time_is_exhausted(monkeypatch):
module = _load("deadline_http")
request = urllib.request.Request("https://scm.bstein.dev/api/v1/x")
killed = []
monkeypatch.setattr(module.os, "killpg", lambda pid, sig: killed.append((pid, sig)))
clock = iter([0.0, 1_000_000.0, 1_000_000.0, 1_000_000.0])
monkeypatch.setattr(module.time, "monotonic", lambda: next(clock))
with pytest.raises(module.PolicyError, match="deadline exceeded"):
module.exchange(
request, maximum=64, timeout=10, popen=_fake_popen(_success_output(b""))
)
assert killed
def test_terminate_tolerates_already_finished_helpers():
module = _load("deadline_http")
process = subprocess.Popen(
["/bin/true"], start_new_session=True, stdout=subprocess.DEVNULL
)
process.wait()
module._terminate(process)
assert process.returncode == 0
def test_child_entrypoint_round_trip_through_real_subprocess(server):
module = _load("deadline_http")
request = urllib.request.Request(server + "/ok")
result = module.exchange(request, maximum=1024, timeout=20)
assert result.status == 200 and result.body == b'{"ok":true}'
def test_open_bounded_adapts_result_to_the_urlopen_interface():
module = _load("deadline_http")
request = urllib.request.Request("https://scm.bstein.dev/api/v1/x")
with module.open_bounded(
request, maximum=64, timeout=10, popen=_fake_popen(_success_output(b"body"))
) as response:
assert response.status == 200
assert response.headers.get_content_type() == "application/json"
assert response.read(2) == b"bo"
assert response.read() == b"dy"