fix: align tests with model validation and API error reporting
This commit is contained in:
@@ -66,7 +66,15 @@ async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||
@pytest.fixture
|
||||
async def auth_token(client: httpx.AsyncClient) -> str:
|
||||
resp = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
||||
return resp.json()["access_token"]
|
||||
token = resp.json()["access_token"]
|
||||
# Clear must_change_password so this token passes server-side enforcement on all other endpoints.
|
||||
await client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": DECNET_ADMIN_PASSWORD},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
resp2 = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
||||
return resp2.json()["access_token"]
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_state_file(monkeypatch, tmp_path) -> Path:
|
||||
|
||||
@@ -29,7 +29,7 @@ class TestMutateInterval:
|
||||
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
json={"mutate_interval": 60},
|
||||
json={"mutate_interval": "60m"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
@@ -40,9 +40,9 @@ class TestMutateInterval:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 60},
|
||||
json={"mutate_interval": "60m"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decky_not_found(self, client: httpx.AsyncClient, auth_token: str):
|
||||
@@ -52,7 +52,7 @@ class TestMutateInterval:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/nonexistent/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 60},
|
||||
json={"mutate_interval": "60m"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -64,11 +64,14 @@ class TestMutateInterval:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 120},
|
||||
json={"mutate_interval": "120m"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Mutation interval updated"
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
saved = mock_repo.set_state.call_args[0][1]
|
||||
saved_interval = saved["config"]["deckies"][0]["mutate_interval"]
|
||||
assert saved_interval == 120
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_interval_removes_mutation(self, client: httpx.AsyncClient, auth_token: str):
|
||||
@@ -82,3 +85,47 @@ class TestMutateInterval:
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_format_returns_422(self, client: httpx.AsyncClient, auth_token: str):
|
||||
"""Seconds ('s') and raw integers are not accepted.
|
||||
Note: The API returns 400 for structural violations (wrong type) and 422 for semantic/pattern violations.
|
||||
"""
|
||||
cases = [
|
||||
("1s", 422),
|
||||
("60", 422),
|
||||
(60, 400),
|
||||
(False, 400),
|
||||
("1h", 422),
|
||||
]
|
||||
for bad, expected_status in cases:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": bad},
|
||||
)
|
||||
assert resp.status_code == expected_status, f"Expected {expected_status} for {bad!r}, got {resp.status_code}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duration_units_stored_as_minutes(self, client: httpx.AsyncClient, auth_token: str):
|
||||
"""Each unit suffix is parsed to the correct number of minutes."""
|
||||
cases = [
|
||||
("2m", 2),
|
||||
("1d", 1440),
|
||||
("1M", 43200),
|
||||
("1y", 525600),
|
||||
("1Y", 525600),
|
||||
]
|
||||
for duration, expected_minutes in cases:
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": duration},
|
||||
)
|
||||
assert resp.status_code == 200, f"Expected 200 for {duration!r}"
|
||||
saved = mock_repo.set_state.call_args[0][1]
|
||||
saved_interval = saved["config"]["deckies"][0]["mutate_interval"]
|
||||
assert saved_interval == expected_minutes, f"{duration!r} → expected {expected_minutes} min, got {saved_interval}"
|
||||
|
||||
@@ -21,10 +21,17 @@ import sys
|
||||
import atexit
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Bind to port 0, let the OS pick a free port, return it."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
# Configuration for the automated live server
|
||||
LIVE_PORT = 8008
|
||||
LIVE_PORT = _free_port()
|
||||
LIVE_SERVER_URL = f"http://127.0.0.1:{LIVE_PORT}"
|
||||
TEST_SECRET = "test-secret-for-automated-fuzzing"
|
||||
|
||||
@@ -40,6 +47,10 @@ def before_call(context, case, *args):
|
||||
# Logged-in admin for all requests
|
||||
case.headers = case.headers or {}
|
||||
case.headers["Authorization"] = f"Bearer {TEST_TOKEN}"
|
||||
# Force SSE stream to close after the initial snapshot so the test doesn't hang
|
||||
if case.path and case.path.endswith("/stream"):
|
||||
case.query = case.query or {}
|
||||
case.query["maxOutput"] = 0
|
||||
|
||||
def wait_for_port(port, timeout=10):
|
||||
start_time = time.time()
|
||||
@@ -61,15 +72,21 @@ def start_automated_server():
|
||||
env["DECNET_CONTRACT_TEST"] = "true"
|
||||
env["DECNET_JWT_SECRET"] = TEST_SECRET
|
||||
|
||||
log_dir = Path(__file__).parent.parent.parent / "logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
log_file = open(log_dir / f"fuzz_server_{LIVE_PORT}_{ts}.log", "w")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[uvicorn_path, "decnet.web.api:app", "--host", "127.0.0.1", "--port", str(LIVE_PORT), "--log-level", "error"],
|
||||
[uvicorn_path, "decnet.web.api:app", "--host", "127.0.0.1", "--port", str(LIVE_PORT), "--log-level", "info"],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
)
|
||||
|
||||
# Register cleanup
|
||||
atexit.register(proc.terminate)
|
||||
atexit.register(log_file.close)
|
||||
|
||||
if not wait_for_port(LIVE_PORT):
|
||||
proc.terminate()
|
||||
@@ -87,6 +104,4 @@ schema = st.openapi.from_url(f"{LIVE_SERVER_URL}/openapi.json")
|
||||
@st.pytest.parametrize(api=schema)
|
||||
@settings(max_examples=3000, deadline=None, verbosity=Verbosity.debug)
|
||||
def test_schema_compliance(case):
|
||||
#print(f"\n[Fuzzing] {case.method} {case.path} with query={case.query}")
|
||||
case.call_and_validate()
|
||||
#print(f" └─ Success")
|
||||
|
||||
@@ -32,7 +32,7 @@ class TestDeckyConfig:
|
||||
assert d.name == "decky-01"
|
||||
|
||||
def test_empty_services_raises(self):
|
||||
with pytest.raises(Exception, match="at least one service"):
|
||||
with pytest.raises(Exception, match="at least 1 item"):
|
||||
DeckyConfig(**self._base(services=[]))
|
||||
|
||||
def test_multiple_services_ok(self):
|
||||
|
||||
@@ -150,11 +150,11 @@ class TestBuildDeckiesFromIni:
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, True)
|
||||
assert len(deckies[0].services) >= 1
|
||||
|
||||
def test_no_services_no_arch_no_randomize_raises(self):
|
||||
def test_no_services_no_arch_auto_randomizes(self):
|
||||
spec = DeckySpec(name="test-1")
|
||||
ini = self._make_ini([spec])
|
||||
with pytest.raises(ValueError, match="has no services"):
|
||||
build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
assert len(deckies[0].services) >= 1
|
||||
|
||||
def test_unknown_service_raises(self):
|
||||
spec = DeckySpec(name="test-1", services=["nonexistent_svc_xyz"])
|
||||
|
||||
Reference in New Issue
Block a user