mirror of
https://github.com/Katakate/k7.git
synced 2026-09-21 09:33:19 +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.
341 lines
14 KiB
Python
341 lines
14 KiB
Python
"""Unit tests for API authentication (mocked file I/O, no k8s)."""
|
|
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from k7.api.main import app, apply_node_scope, authorize_namespace, load_api_keys
|
|
from k7.core.models import OperationResult
|
|
|
|
TEST_KEY = "k7-test-secret-key-abc123"
|
|
TEST_KEY_HASH = hashlib.sha256(TEST_KEY.encode()).hexdigest()
|
|
|
|
|
|
def _make_keys_data(
|
|
*,
|
|
expires: int | None = None,
|
|
last_used: int | None = None,
|
|
namespaces: list[str] | None = None,
|
|
nodes: list[str] | None = None,
|
|
) -> dict:
|
|
entry: dict = {"name": "test-key"}
|
|
if expires is not None:
|
|
entry["expires"] = expires
|
|
if last_used is not None:
|
|
entry["last_used"] = last_used
|
|
if namespaces is not None:
|
|
entry["namespaces"] = namespaces
|
|
if nodes is not None:
|
|
entry["nodes"] = nodes
|
|
return {TEST_KEY_HASH: entry}
|
|
|
|
|
|
@pytest.fixture()
|
|
def keys_file(tmp_path: Path) -> Path:
|
|
return tmp_path / "api_keys.json"
|
|
|
|
|
|
@pytest.fixture()
|
|
def _patch_keys_file(keys_file: Path):
|
|
with patch("k7.api.main.API_KEYS_FILE", keys_file):
|
|
yield
|
|
|
|
|
|
# --- load_api_keys ---
|
|
|
|
|
|
class TestLoadApiKeys:
|
|
def test_missing_file(self, _patch_keys_file, keys_file: Path):
|
|
assert load_api_keys() == {}
|
|
|
|
def test_empty_file_fails_loud(self, _patch_keys_file, keys_file: Path):
|
|
# An existing-but-unparseable store is a deployment bug: it must be
|
|
# a loud 500, not a silent {} that rejects every key as "invalid".
|
|
from fastapi import HTTPException
|
|
|
|
keys_file.write_text("")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
load_api_keys()
|
|
assert exc_info.value.status_code == 500
|
|
assert "corrupt" in exc_info.value.detail
|
|
|
|
def test_valid_json(self, _patch_keys_file, keys_file: Path):
|
|
data = _make_keys_data()
|
|
keys_file.write_text(json.dumps(data))
|
|
result = load_api_keys()
|
|
assert TEST_KEY_HASH in result
|
|
assert result[TEST_KEY_HASH]["name"] == "test-key"
|
|
|
|
def test_expired_keys_purged(self, _patch_keys_file, keys_file: Path):
|
|
expired_ts = int(time.time()) - 3600
|
|
data = _make_keys_data(expires=expired_ts)
|
|
keys_file.write_text(json.dumps(data))
|
|
result = load_api_keys()
|
|
assert TEST_KEY_HASH not in result
|
|
|
|
|
|
# --- verify_api_key via httpx.AsyncClient ---
|
|
|
|
|
|
class TestVerifyApiKey:
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts)
|
|
keys_file.write_text(json.dumps(data))
|
|
self.keys_file = keys_file
|
|
|
|
async def test_valid_x_api_key(self):
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get("/health")
|
|
assert resp.status_code == 200
|
|
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
# May fail with 400/500 (no k8s), but should NOT be 401
|
|
assert resp.status_code != 401
|
|
|
|
async def test_valid_bearer_token(self):
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"Authorization": f"Bearer {TEST_KEY}"},
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
assert resp.status_code != 401
|
|
|
|
async def test_missing_key_returns_401(self):
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_wrong_key_returns_401(self):
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"X-API-Key": "wrong-key-value"},
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_expired_key_returns_401(self):
|
|
expired_ts = int(time.time()) - 3600
|
|
data = _make_keys_data(expires=expired_ts)
|
|
self.keys_file.write_text(json.dumps(data))
|
|
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# --- authorize_namespace ---
|
|
|
|
|
|
class TestAuthorizeNamespace:
|
|
def test_unrestricted_key_allowed_everywhere(self):
|
|
authorize_namespace({"name": "u"}, "alpha")
|
|
authorize_namespace({"name": "u", "namespaces": []}, "beta")
|
|
authorize_namespace({"name": "u"}, None, all_namespaces=True)
|
|
|
|
def test_scoped_key_allowed_in_listed_namespace(self):
|
|
authorize_namespace({"namespaces": ["alpha", "gamma"]}, "alpha")
|
|
|
|
def test_scoped_key_denied_other_namespace(self):
|
|
with pytest.raises(HTTPException) as exc:
|
|
authorize_namespace({"namespaces": ["alpha"]}, "beta")
|
|
assert exc.value.status_code == 403
|
|
|
|
def test_scoped_key_denied_all_namespaces(self):
|
|
with pytest.raises(HTTPException) as exc:
|
|
authorize_namespace({"namespaces": ["alpha"]}, "alpha", all_namespaces=True)
|
|
assert exc.value.status_code == 403
|
|
|
|
def test_scoped_key_denied_implicit_all(self):
|
|
with pytest.raises(HTTPException) as exc:
|
|
authorize_namespace({"namespaces": ["alpha"]}, None)
|
|
assert exc.value.status_code == 403
|
|
|
|
async def test_scoped_key_list_other_namespace_returns_403(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts, namespaces=["alpha"])
|
|
keys_file.write_text(json.dumps(data))
|
|
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get(
|
|
"/api/v1/sandboxes",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
params={"namespace": "beta"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
async def test_scoped_key_nodes_storage_returns_403(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts, namespaces=["alpha"])
|
|
keys_file.write_text(json.dumps(data))
|
|
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get(
|
|
"/api/v1/nodes/storage",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
)
|
|
assert resp.status_code == 403
|
|
assert "all-namespaces" in resp.json()["error"]["message"]
|
|
|
|
async def test_unscoped_key_nodes_storage_returns_200(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts)
|
|
keys_file.write_text(json.dumps(data))
|
|
payload = {"node-a": {"kata_thinpool": {"size_bytes": 1}}}
|
|
|
|
with patch("k7.api.main.K7Core") as core_cls:
|
|
core_cls.return_value.nodes_storage = AsyncMock(return_value=payload)
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get(
|
|
"/api/v1/nodes/storage",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["data"] == payload
|
|
core_cls.return_value.nodes_storage.assert_awaited_once()
|
|
|
|
|
|
# --- apply_node_scope ---
|
|
|
|
|
|
class TestApplyNodeScope:
|
|
def test_unrestricted_key_keeps_caller_pin(self):
|
|
assert apply_node_scope({"name": "u"}, None) is None
|
|
assert apply_node_scope({"name": "u", "nodes": []}, "k7-node-01") == "k7-node-01"
|
|
|
|
def test_single_node_key_auto_pins(self):
|
|
assert apply_node_scope({"nodes": ["k7-node-01"]}, None) == "k7-node-01"
|
|
|
|
def test_single_node_key_allows_listed_node(self):
|
|
assert apply_node_scope({"nodes": ["k7-node-01"]}, "k7-node-01") == "k7-node-01"
|
|
|
|
def test_scoped_key_denied_other_node(self):
|
|
with pytest.raises(HTTPException) as exc:
|
|
apply_node_scope({"nodes": ["k7-node-01"]}, "k7-node-02")
|
|
assert exc.value.status_code == 403
|
|
assert "k7-node-02" in str(exc.value.detail)
|
|
|
|
def test_multi_node_key_requires_explicit_node(self):
|
|
with pytest.raises(HTTPException) as exc:
|
|
apply_node_scope({"nodes": ["k7-node-01", "k7-node-02"]}, None)
|
|
assert exc.value.status_code == 403
|
|
assert "explicit allowed node" in str(exc.value.detail)
|
|
|
|
def test_multi_node_key_allows_listed_node(self):
|
|
assert apply_node_scope({"nodes": ["k7-node-01", "k7-node-02"]}, "k7-node-02") == "k7-node-02"
|
|
|
|
async def test_single_node_key_create_stamps_node_name(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts, nodes=["k7-node-01"])
|
|
keys_file.write_text(json.dumps(data))
|
|
|
|
with patch("k7.api.main.K7Core") as core_cls:
|
|
core_cls.return_value.create_sandbox = AsyncMock(return_value=OperationResult(success=True))
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
cfg = core_cls.return_value.create_sandbox.await_args.args[0]
|
|
assert cfg.node_name == "k7-node-01"
|
|
|
|
async def test_scoped_key_create_other_node_returns_403(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts, nodes=["k7-node-01"])
|
|
keys_file.write_text(json.dumps(data))
|
|
|
|
with patch("k7.api.main.K7Core") as core_cls:
|
|
core_cls.return_value.create_sandbox = AsyncMock(return_value=OperationResult(success=True))
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
json={"name": "t", "image": "alpine", "node_name": "k7-node-02"},
|
|
)
|
|
assert resp.status_code == 403
|
|
core_cls.return_value.create_sandbox.assert_not_called()
|
|
|
|
async def test_node_scoped_key_nodes_storage_returns_403(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts, nodes=["k7-node-01"])
|
|
keys_file.write_text(json.dumps(data))
|
|
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get(
|
|
"/api/v1/nodes/storage",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
)
|
|
assert resp.status_code == 403
|
|
assert "cluster-wide node" in resp.json()["error"]["message"]
|
|
|
|
async def test_restore_stamps_node_name(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts, nodes=["k7-node-01"])
|
|
keys_file.write_text(json.dumps(data))
|
|
|
|
with patch("k7.api.main.K7Core") as core_cls:
|
|
core_cls.return_value.restore_sandbox = AsyncMock(return_value=OperationResult(success=True))
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/snapshots/snap1/restore",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
json={"new_sandbox_name": "restored"},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
kwargs = core_cls.return_value.restore_sandbox.await_args.kwargs
|
|
assert kwargs["node_name"] == "k7-node-01"
|
|
|
|
async def test_fork_denied_when_source_on_other_node(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts, nodes=["k7-node-01"])
|
|
keys_file.write_text(json.dumps(data))
|
|
source = SimpleNamespace(name="src", node="k7-node-02")
|
|
|
|
with patch("k7.api.main.K7Core") as core_cls:
|
|
core_cls.return_value.list_sandboxes = AsyncMock(return_value=[source])
|
|
core_cls.return_value.fork_sandbox = AsyncMock(return_value=OperationResult(success=True))
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes/src/fork",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
json={"new_name": "forked"},
|
|
)
|
|
assert resp.status_code == 403
|
|
core_cls.return_value.fork_sandbox.assert_not_called()
|