Files
k7/tests/unit/test_api_auth.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

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()