Skip to content

Commit 6770d95

Browse files
committed
test: cover control-plane helpers
1 parent c383214 commit 6770d95

File tree

3 files changed

+197
-14
lines changed

3 files changed

+197
-14
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ audit:
3535

3636
coverage:
3737
uv pip install pytest-cov
38-
uv run --no-sync python -m pytest --cov=src --cov-report=term-missing --cov-fail-under=65
38+
uv run --no-sync python -m pytest --cov=src --cov-report=term-missing --cov-fail-under=66
3939

4040
scan-images:
4141
docker build -t nimbus-control-plane:ci .

tests/test_control_plane_app.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from datetime import datetime, timezone
5+
6+
import pytest
7+
from fastapi import HTTPException
8+
from starlette.requests import Request
9+
10+
from nimbus.control_plane import app as control_app
11+
12+
13+
def _make_request(headers: dict[str, str], client_ip: str = "203.0.113.10") -> Request:
14+
scope = {
15+
"type": "http",
16+
"method": "GET",
17+
"path": "/test",
18+
"scheme": "http",
19+
"client": (client_ip, 12345),
20+
"headers": [(key.encode("latin-1"), value.encode("latin-1")) for key, value in headers.items()],
21+
}
22+
23+
async def receive() -> dict: # pragma: no cover - protocol shim
24+
return {"type": "http.request", "body": b"", "more_body": False}
25+
26+
return Request(scope, receive)
27+
28+
29+
def test_default_cache_scope() -> None:
30+
assert control_app._default_cache_scope(42) == "pull:org-42,push:org-42"
31+
32+
33+
def test_validate_webhook_timestamp_accepts_current(monkeypatch) -> None:
34+
now = int(1_700_000_000)
35+
result = control_app._validate_webhook_timestamp(str(now), tolerance_seconds=30, now=now)
36+
assert result == now
37+
38+
39+
@pytest.mark.parametrize("value", ["", "not-int"])
40+
def test_validate_webhook_timestamp_rejects_invalid(value: str) -> None:
41+
with pytest.raises(HTTPException) as exc:
42+
control_app._validate_webhook_timestamp(value, tolerance_seconds=10)
43+
assert exc.value.status_code == 400
44+
45+
46+
def test_validate_webhook_timestamp_rejects_stale() -> None:
47+
now = 1_700_000_000
48+
with pytest.raises(HTTPException) as exc:
49+
control_app._validate_webhook_timestamp(str(now - 100), tolerance_seconds=10, now=now)
50+
assert exc.value.status_code == 409
51+
52+
53+
def test_get_client_ip_without_trusted_proxies() -> None:
54+
request = _make_request({}, client_ip="198.51.100.7")
55+
ip = control_app.get_client_ip(request, trusted_proxies=[])
56+
assert ip == "198.51.100.7"
57+
58+
59+
def test_get_client_ip_with_trusted_proxy() -> None:
60+
headers = {"x-forwarded-for": "10.0.0.5"}
61+
request = _make_request(headers, client_ip="192.0.2.1")
62+
ip = control_app.get_client_ip(request, trusted_proxies=["192.0.2.0/24"])
63+
assert ip == "10.0.0.5"
64+
65+
66+
def test_get_client_ip_with_untrusted_proxy() -> None:
67+
headers = {"x-forwarded-for": "10.0.0.5"}
68+
request = _make_request(headers, client_ip="203.0.113.1")
69+
ip = control_app.get_client_ip(request, trusted_proxies=["192.0.2.0/24"])
70+
assert ip == "203.0.113.1"
71+
72+
73+
def test_row_to_ssh_session_parses_strings() -> None:
74+
row = {
75+
"session_id": "sess",
76+
"job_id": 1,
77+
"agent_id": "agent",
78+
"host_port": 2222,
79+
"created_at": datetime(2024, 1, 1, tzinfo=timezone.utc).isoformat(),
80+
"expires_at": datetime(2024, 1, 1, 1, tzinfo=timezone.utc).isoformat(),
81+
}
82+
session = control_app._row_to_ssh_session(row)
83+
assert session.session_id == "sess"
84+
assert session.created_at.tzinfo is not None
85+
assert session.expires_at > session.created_at
86+
87+
88+
def test_rate_limiter_allows_within_limit(monkeypatch) -> None:
89+
limiter = control_app.RateLimiter(limit=2, interval=1.0)
90+
times = [0.0, 0.1, 0.2]
91+
92+
def fake_time() -> float:
93+
return times.pop(0)
94+
95+
monkeypatch.setattr(control_app.time, "time", fake_time)
96+
assert limiter.allow("key") is True
97+
assert limiter.allow("key") is True
98+
assert limiter.allow("key") is False
99+
100+
101+
def test_rate_limiter_disabled() -> None:
102+
limiter = control_app.RateLimiter(limit=0, interval=1.0)
103+
for _ in range(5):
104+
assert limiter.allow("key") is True

tests/test_host_agent_reaper.py

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@
77
from nimbus.host_agent import reaper
88

99

10+
class FakeProcess:
11+
def __init__(self, stdout: bytes = b"", stderr: bytes = b"", returncode: int = 0):
12+
self._stdout = stdout
13+
self._stderr = stderr
14+
self.returncode = returncode
15+
self.terminated = False
16+
self.killed = False
17+
18+
async def communicate(self) -> tuple[bytes, bytes]:
19+
return self._stdout, self._stderr
20+
21+
def terminate(self) -> None:
22+
self.terminated = True
23+
24+
async def wait(self) -> int:
25+
await asyncio.sleep(0)
26+
if self.returncode is None:
27+
self.returncode = 0
28+
return self.returncode
29+
30+
def kill(self) -> None:
31+
self.killed = True
32+
33+
1034
@pytest.mark.asyncio
1135
async def test_reap_stale_resources(monkeypatch):
1236
async def fake_find_taps(prefix: str): # noqa: ANN001
@@ -34,22 +58,77 @@ async def fake_kill(pid: int): # noqa: ANN001
3458
assert stats == {"taps_deleted": 2, "bridges_deleted": 2, "processes_killed": 2}
3559

3660

37-
class DummyProcess:
38-
def __init__(self) -> None:
39-
self.returncode: int | None = None
40-
self.terminated = False
41-
self.killed = False
61+
@pytest.mark.asyncio
62+
async def test_find_stale_taps_parses_output(monkeypatch):
63+
async def fake_exec(*args, **kwargs): # noqa: ANN001
64+
return FakeProcess(stdout=b"nimbus0001: tap\ninvalid\n")
4265

43-
def terminate(self) -> None:
44-
self.terminated = True
66+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
67+
taps = await reaper._find_stale_taps("nimbus")
68+
assert taps == ["nimbus0001"]
4569

46-
async def wait(self) -> int:
47-
await asyncio.sleep(0)
48-
self.returncode = 0
49-
return 0
5070

51-
def kill(self) -> None:
52-
self.killed = True
71+
@pytest.mark.asyncio
72+
async def test_find_stale_taps_handles_missing_command(monkeypatch):
73+
async def fake_exec(*args, **kwargs): # noqa: ANN001
74+
raise FileNotFoundError
75+
76+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
77+
taps = await reaper._find_stale_taps("nimbus")
78+
assert taps == []
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_delete_tap_success_and_failure(monkeypatch):
83+
calls: list[bool] = []
84+
85+
async def fake_exec(*args, **kwargs): # noqa: ANN001
86+
success = len(calls) == 0
87+
calls.append(success)
88+
return FakeProcess(returncode=0 if success else 1)
89+
90+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
91+
assert await reaper._delete_tap("tap0") is True
92+
assert await reaper._delete_tap("tap0") is False
93+
94+
95+
@pytest.mark.asyncio
96+
async def test_delete_bridge_brings_down_then_deletes(monkeypatch):
97+
sequence: list[tuple[str, ...]] = []
98+
99+
async def fake_exec(*args, **kwargs): # noqa: ANN001
100+
sequence.append(tuple(args))
101+
return FakeProcess(returncode=0)
102+
103+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
104+
assert await reaper._delete_bridge("br0") is True
105+
assert sequence[0][:3] == ("ip", "link", "set")
106+
assert sequence[1][:3] == ("ip", "link", "del")
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_find_stale_firecracker_processes(monkeypatch):
111+
async def fake_exec(*args, **kwargs): # noqa: ANN001
112+
return FakeProcess(stdout=b"123\nabc\n456\n")
113+
114+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
115+
pids = await reaper._find_stale_firecracker_processes()
116+
assert pids == [123, 456]
117+
118+
119+
@pytest.mark.asyncio
120+
async def test_kill_process_handles_failure(monkeypatch):
121+
async def fake_exec(*args, **kwargs): # noqa: ANN001
122+
return FakeProcess(returncode=1)
123+
124+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
125+
assert await reaper._kill_process(999) is False
126+
127+
128+
class DummyProcess(FakeProcess):
129+
def __init__(self) -> None:
130+
super().__init__(returncode=0)
131+
self.returncode = None
53132

54133

55134
@pytest.mark.asyncio

0 commit comments

Comments
 (0)