mirror of
https://github.com/Katakate/k7.git
synced 2026-09-21 17:43:20 +00:00
13fafe0997
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.
194 lines
9.2 KiB
Python
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
|