Files
k7/tests/unit/test_api_lifecycle_endpoints.py
T
G 13fafe0997 Release 0.4.0
Per-key node pins, k7d-fc pause/resume/exec, and HA-soak fixes. Playbook
pins k7d 0.7.0. GitHub .deb, Launchpad PPA, and PyPI k7-sdk are 0.4.0.
2026-09-19 23:22:14 +02:00

194 lines
9.2 KiB
Python

"""Unit tests for the pause/resume/fork API endpoints.
Hits the FastAPI ASGI app directly with a mocked K7Core, so these tests
need no Kubernetes cluster. They verify request/response shape, body
forwarding into K7Core, and HTTP status mapping for fork errors.
"""
import hashlib
import json
import time
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from k7.api.main import app
from k7.core.models import OperationResult
TEST_KEY = "k7-test-secret-key-lifecycle"
TEST_KEY_HASH = hashlib.sha256(TEST_KEY.encode()).hexdigest()
@pytest.fixture()
def keys_file(tmp_path: Path):
p = tmp_path / "api_keys.json"
future_ts = int(time.time()) + 86400
p.write_text(json.dumps({TEST_KEY_HASH: {"name": "test-key", "expires": future_ts, "last_used": None}}))
with patch("k7.api.main.API_KEYS_FILE", p):
yield p
async def _post(client, path, body=None, headers=None):
return await client.post(path, json=body, headers=headers or {"X-API-Key": TEST_KEY})
# --- /pause -----------------------------------------------------------------
class TestPauseEndpoint:
async def test_pause_forwards_body_to_core(self, keys_file):
fake = AsyncMock(return_value=OperationResult(success=True, message="Sandbox demo paused (replicas=0)."))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.pause_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(
client,
"/api/v1/sandboxes/demo/pause",
{"namespace": "ns", "snapshot": "snap1"},
)
assert resp.status_code == 200
assert resp.json() == {"data": {"message": "Sandbox demo paused (replicas=0)."}}
fake.assert_awaited_once_with(name="demo", namespace="ns", snapshot_name="snap1")
async def test_pause_empty_body_defaults(self, keys_file):
fake = AsyncMock(return_value=OperationResult(success=True, message="ok"))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.pause_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(client, "/api/v1/sandboxes/demo/pause", {})
assert resp.status_code == 200
fake.assert_awaited_once_with(name="demo", namespace="default", snapshot_name=None)
async def test_pause_ignores_legacy_pvc_and_snapshot_class(self, keys_file):
"""pvc/snapshot_class in the body must NOT be forwarded to Core.
We accept them silently for one release rather than 400ing — but the
server-side behaviour is exactly as if they weren't sent.
"""
fake = AsyncMock(return_value=OperationResult(success=True, message="ok"))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.pause_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(
client,
"/api/v1/sandboxes/demo/pause",
{"snapshot": "snap1", "pvc": "ignored", "snapshot_class": "ignored"},
)
assert resp.status_code == 200
fake.assert_awaited_once_with(name="demo", namespace="default", snapshot_name="snap1")
async def test_pause_core_failure_returns_400(self, keys_file):
fake = AsyncMock(return_value=OperationResult(success=False, error="boom"))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.pause_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(client, "/api/v1/sandboxes/demo/pause", {})
assert resp.status_code == 400
assert resp.json()["error"]["message"] == "boom"
async def test_pause_requires_auth(self, keys_file):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post("/api/v1/sandboxes/demo/pause", json={})
assert resp.status_code == 401
# --- /resume ----------------------------------------------------------------
class TestResumeEndpoint:
async def test_resume_forwards_namespace(self, keys_file):
fake = AsyncMock(return_value=OperationResult(success=True, message="Sandbox demo resumed (replicas=1)."))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.resume_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(client, "/api/v1/sandboxes/demo/resume", {"namespace": "ns"})
assert resp.status_code == 200
fake.assert_awaited_once_with(name="demo", namespace="ns")
async def test_resume_empty_body_uses_default_namespace(self, keys_file):
fake = AsyncMock(return_value=OperationResult(success=True, message="ok"))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.resume_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(client, "/api/v1/sandboxes/demo/resume", None)
assert resp.status_code == 200
fake.assert_awaited_once_with(name="demo", namespace="default")
# --- /fork ------------------------------------------------------------------
class TestForkEndpoint:
async def test_fork_success_returns_201(self, keys_file):
fake = AsyncMock(return_value=OperationResult(success=True, message="Forked demo -> child in 1.0s"))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.fork_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(
client,
"/api/v1/sandboxes/demo/fork",
{"new_name": "child", "namespace": "ns", "snapshot": "snap"},
)
assert resp.status_code == 201
body = resp.json()["data"]
assert body["name"] == "child"
assert body["namespace"] == "ns"
assert body["source"] == "demo"
assert "Forked" in body["message"]
assert resp.headers["location"] == "/api/v1/sandboxes/child?namespace=ns"
fake.assert_awaited_once_with(
source_name="demo",
new_name="child",
namespace="ns",
snapshot_name="snap",
)
async def test_fork_missing_new_name_returns_400(self, keys_file):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(client, "/api/v1/sandboxes/demo/fork", {})
assert resp.status_code == 400
assert "new_name is required" in resp.json()["error"]["message"]
async def test_fork_conflict_returns_409(self, keys_file):
fake = AsyncMock(return_value=OperationResult(success=False, error="Sandbox child already exists"))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.fork_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(
client,
"/api/v1/sandboxes/demo/fork",
{"new_name": "child"},
)
assert resp.status_code == 409
assert "already exists" in resp.json()["error"]["message"]
async def test_fork_source_not_found_returns_404(self, keys_file):
fake = AsyncMock(return_value=OperationResult(success=False, error="Source root PVC demo-root-lh not found"))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.fork_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(client, "/api/v1/sandboxes/demo/fork", {"new_name": "child"})
assert resp.status_code == 404
async def test_fork_other_error_returns_400(self, keys_file):
fake = AsyncMock(return_value=OperationResult(success=False, error="some other failure"))
with patch("k7.api.main.K7Core") as core_cls:
core_cls.return_value.fork_sandbox = fake
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await _post(client, "/api/v1/sandboxes/demo/fork", {"new_name": "child"})
assert resp.status_code == 400