Files
k7/tests/unit/test_api_auth.py
T
G 76a8139774 Release 0.3.0
HTTPS-by-default for k7-api, cluster-wide Cilium isolation, first-class
--docker on Kata and k7d, and RuntimeClass k7-fc. Playbook pins k7d 0.6.0.
2026-09-13 17:30:37 +02:00

220 lines
8.1 KiB
Python

"""Unit tests for API authentication (mocked file I/O, no k8s)."""
import hashlib
import json
import time
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from fastapi import HTTPException
from k7.api.main import app, authorize_namespace, load_api_keys
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,
) -> 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
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()