litellm/tests/unit/test_e2e_egress_sentinel.py
yuneng-jiang f6882246d4
test: move tests/test_litellm root and small trees into tests/unit (#43186)
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rename fork-flag to unit-flag now that it applies on every event

* test: move tests/test_litellm root and small trees into tests/unit

Pure renames, no content changes. Follow-up commits in this PR fix
references, merge the three files that already existed in tests/unit,
keep live-provider tests in tests/test_litellm and wire CI.

* test: carry tests/test_litellm conftest isolation into tests/unit

Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS,
proxy-URL and keychain env, and session-end client cleanup now reset for
unit tests too. The environment isolation owns its MonkeyPatch so a test's
own monkeypatch is undone before the model-cost teardown runs.

* test: merge, split and prune the moved root and small-tree tests

Merge batches/test_batch_utils.py and the chat_completions and messages
dispatch tests into the files that already existed in tests/unit. Keep
the live Gemini interactions tests, the async image-fetch format test and
the OpenAI embedding scorer test in tests/test_litellm since they need
real network or keys. Put test_router.py under tests/unit/test_router so
the existing package no longer shadows it. Delete eight tests the audit
found superseded by stronger ones kept in this move.

* ci: run the moved root and small-tree tests under their legacy flags

Add the misc and responses-caching-types flags to unit_selection.sh and
CircleCI, extend enterprise-routing and mcp-integration, and point the
legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest
and change classifier at the new paths.

* test: make the new tests/unit directories packages

tests/unit/test_package_layout.py requires every directory to carry an
__init__.py, and without one the moved and retained
test_litellm_responses_bridge.py modules collide on import.

* test: scope the unit socket block to tests/unit in shared sessions

The GHA shards collect the legacy test-path and the unit selection in one
pytest session. The unit conftest's loopback-only block leaked into legacy
modules that reach the network at import. The legacy conftest now lifts the
restriction at collect and setup time, and the unit conftest re-applies it
when collecting its own modules.

* test: give the shard-script tests their own GITHUB_OUTPUT

They only passed where the runner set it. The CircleCI unit job's env
allowlist drops it, so the script's redirect failed there.

* test: point the router and module-deletion checks at tests/unit

router_code_coverage and code_qa_check_tests only searched tests/test_litellm,
so the moved router tests no longer counted. The two silent-experiment tests
the audit deleted were the only direct callers of those methods; they are
replaced with tests that assert the forwarded shadow request and the
recursion guard.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 11:30:43 -07:00

172 lines
5.7 KiB
Python

"""Tests for .github/scripts/e2e_egress_sentinel.py.
The replay lane's zero-egress proof is only as good as this sentinel: it pins the
provider hosts to a local sink and counts every connection that reaches them, so
a single escaped provider call turns the run red. The contract locked in here is
that the counter counts (each accepted connection is exactly one recorded hit),
that ``assert-empty`` is the pass/fail gate around that count, and that the hosts
file it edits is always handed back exactly as it was found.
"""
from __future__ import annotations
import importlib.util
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Final
_REPO_ROOT: Final = Path(__file__).resolve().parents[2]
_MODULE_PATH: Final = _REPO_ROOT / ".github" / "scripts" / "e2e_egress_sentinel.py"
_spec: Final = importlib.util.spec_from_file_location("e2e_egress_sentinel", _MODULE_PATH)
sentinel: Final = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = sentinel # @dataclass(slots=True) rebuilds via sys.modules
_spec.loader.exec_module(sentinel)
def _hit_lines(hits_file: Path) -> list[str]:
if not hits_file.exists():
return []
return [line for line in hits_file.read_text(encoding="utf-8").splitlines() if line.strip()]
def test_pin_block_lists_every_host_against_the_sink():
block = sentinel._pin_block("127.0.0.1", ("api.openai.com", "api.anthropic.com"))
assert "127.0.0.1\tapi.openai.com" in block
assert "127.0.0.1\tapi.anthropic.com" in block
assert sentinel._BLOCK_BEGIN in block and sentinel._BLOCK_END in block
def test_install_and_restore_round_trips_an_existing_hosts_file(tmp_path):
hosts = tmp_path / "hosts"
original = "127.0.0.1\tlocalhost\n255.255.255.255\tbroadcasthost\n"
hosts.write_text(original, encoding="utf-8")
saved = sentinel._install_pins(hosts, "127.0.0.1", ("api.openai.com",))
assert saved == original.encode()
assert "api.openai.com" in hosts.read_text(encoding="utf-8")
sentinel._restore_pins(hosts, saved)
assert hosts.read_text(encoding="utf-8") == original
def test_install_on_a_missing_hosts_file_creates_then_restore_empties(tmp_path):
hosts = tmp_path / "hosts"
saved = sentinel._install_pins(hosts, "127.0.0.1", ("api.anthropic.com",))
assert saved == b""
assert "api.anthropic.com" in hosts.read_text(encoding="utf-8")
sentinel._restore_pins(hosts, saved)
assert hosts.read_text(encoding="utf-8") == ""
def test_assert_empty_passes_when_no_calls(tmp_path):
absent = tmp_path / "absent.jsonl"
assert sentinel.assert_empty(absent) == 0
empty = tmp_path / "empty.jsonl"
empty.write_text("\n \n", encoding="utf-8")
assert sentinel.assert_empty(empty) == 0
def test_assert_empty_fails_when_calls_recorded(tmp_path):
hits = tmp_path / "hits.jsonl"
hits.write_text('{"port": 443, "peer": ["127.0.0.1", 5]}\n', encoding="utf-8")
assert sentinel.assert_empty(hits) == 1
def test_accept_loop_records_exactly_one_hit_per_connection(tmp_path):
hits_file = tmp_path / "hits.jsonl"
hits_file.write_text("", encoding="utf-8")
listener = sentinel._bind("127.0.0.1", 0)
port = listener.getsockname()[1]
stop = threading.Event()
hits = sentinel._HitLog(path=hits_file, _lock=threading.Lock())
worker = threading.Thread(target=sentinel._serve_socket, args=(listener, port, hits, stop), daemon=True)
worker.start()
try:
for _ in range(3):
conn = socket.create_connection(("127.0.0.1", port), timeout=2)
conn.close()
deadline = time.time() + 3
while time.time() < deadline and len(_hit_lines(hits_file)) < 3:
time.sleep(0.02)
finally:
stop.set()
listener.close()
worker.join(timeout=3)
assert len(_hit_lines(hits_file)) == 3
def test_serve_end_to_end_pins_counts_and_restores(tmp_path):
hosts = tmp_path / "hosts"
hosts.write_text("127.0.0.1\tlocalhost\n", encoding="utf-8")
hits = tmp_path / "hits.jsonl"
ready = tmp_path / "ready"
pidf = tmp_path / "pid"
port = _free_port()
proc = subprocess.Popen(
[
sys.executable,
str(_MODULE_PATH),
"serve",
"--host",
"api.openai.com",
"--host",
"api.anthropic.com",
"--sink-address",
"127.0.0.1",
"--port",
str(port),
"--hits-file",
str(hits),
"--ready-file",
str(ready),
"--pid-file",
str(pidf),
"--hosts-file",
str(hosts),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
_wait_for(lambda: ready.exists(), timeout=10)
pinned = hosts.read_text(encoding="utf-8")
assert "api.openai.com" in pinned and "api.anthropic.com" in pinned
for _ in range(2):
conn = socket.create_connection(("127.0.0.1", port), timeout=2)
conn.close()
_wait_for(lambda: len(_hit_lines(hits)) >= 2, timeout=5)
assert sentinel.assert_empty(hits) == 1
finally:
proc.terminate()
proc.wait(timeout=10)
assert hosts.read_text(encoding="utf-8") == "127.0.0.1\tlocalhost\n"
assert not ready.exists()
def _free_port() -> int:
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
probe.close()
return port
def _wait_for(predicate, *, timeout: float) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return
time.sleep(0.05)
raise AssertionError("condition never became true within the timeout")