Files
k7/tests/unit/test_core_subprocess.py
T
G a939e693d0 Release 0.2.1
Security release for the k7-api control plane.

Fixes a server-side request forgery reachable through a sandbox's image
registry host: registry hosts are now resolved and checked against
public/allowlisted ranges before any OCI fetch, the localhost-to-plain-HTTP
downgrade is gone, and redirects are disabled so an allowlisted host cannot
bounce the request inward.

Adds optional per-key namespace authorization, so an API key can be
confined to the namespaces it owns and cannot perform all-namespaces
operations. Keys without a scope keep their previous unrestricted
behaviour, so upgrading changes nothing until you scope your keys.

Both issues were reported privately by Jirayu Thongchotchaung, who held
disclosure until this release was available. See CHANGELOG.md and the
published advisories for detail.
2026-08-16 00:16:48 +02:00

324 lines
12 KiB
Python

"""Unit tests for K7Core methods that use async K8s API or HTTP (all mocked)."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from k7.core.core import K7Core
# --- _parse_image_reference ---
class TestParseImageReference:
def test_simple_dockerhub(self, core: K7Core):
reg, repo, tag = core._parse_image_reference("alpine:3.21")
assert reg == "registry-1.docker.io"
assert repo == "library/alpine"
assert tag == "3.21"
def test_dockerhub_no_tag(self, core: K7Core):
reg, repo, tag = core._parse_image_reference("alpine")
assert reg == "registry-1.docker.io"
assert repo == "library/alpine"
assert tag == "latest"
def test_dockerhub_user_repo(self, core: K7Core):
reg, repo, tag = core._parse_image_reference("katakate/k7-api:0.0.1")
assert reg == "registry-1.docker.io"
assert repo == "katakate/k7-api"
assert tag == "0.0.1"
def test_ghcr(self, core: K7Core):
reg, repo, tag = core._parse_image_reference("ghcr.io/katakate/k7-api:v1")
assert reg == "ghcr.io"
assert repo == "katakate/k7-api"
assert tag == "v1"
def test_digest(self, core: K7Core):
reg, repo, tag = core._parse_image_reference("alpine@sha256:abc123")
assert reg == "registry-1.docker.io"
assert repo == "library/alpine"
assert tag == "sha256:abc123"
def test_registry_with_port(self, core: K7Core):
reg, repo, tag = core._parse_image_reference("localhost:5000/myimage:dev")
assert reg == "localhost:5000"
assert repo == "myimage"
assert tag == "dev"
def test_quay(self, core: K7Core):
reg, repo, tag = core._parse_image_reference("quay.io/prometheus/node-exporter:v1.8.0")
assert reg == "quay.io"
assert repo == "prometheus/node-exporter"
assert tag == "v1.8.0"
# --- _get_image_entrypoint_cmd (registry-based) ---
class TestGetImageEntrypointCmd:
def _mock_registry_config(self, entrypoint=None, cmd=None):
return {"config": {"Entrypoint": entrypoint, "Cmd": cmd}}
async def test_returns_entrypoint_and_cmd(self, core: K7Core):
config = self._mock_registry_config(["/init"], ["run"])
with patch.object(core, "_get_registry_image_config", new_callable=AsyncMock, return_value=config):
ep, cmd = await core._get_image_entrypoint_cmd("alpine:3.18")
assert ep == ["/init"]
assert cmd == ["run"]
async def test_entrypoint_only(self, core: K7Core):
config = self._mock_registry_config(["/start"], None)
with patch.object(core, "_get_registry_image_config", new_callable=AsyncMock, return_value=config):
ep, cmd = await core._get_image_entrypoint_cmd("img:1")
assert ep == ["/start"]
assert cmd == []
async def test_cmd_only(self, core: K7Core):
config = self._mock_registry_config(None, ["/bin/sh"])
with patch.object(core, "_get_registry_image_config", new_callable=AsyncMock, return_value=config):
ep, cmd = await core._get_image_entrypoint_cmd("img:1")
assert ep == []
assert cmd == ["/bin/sh"]
async def test_registry_failure_returns_empty(self, core: K7Core):
with patch.object(core, "_get_registry_image_config", new_callable=AsyncMock, side_effect=Exception("network")):
ep, cmd = await core._get_image_entrypoint_cmd("img:1")
assert ep == []
assert cmd == []
async def test_empty_image_string(self, core: K7Core):
ep, cmd = await core._get_image_entrypoint_cmd("")
assert ep == []
assert cmd == []
# --- _create_volume_snapshot ---
class TestCreateVolumeSnapshot:
async def test_success(self, core: K7Core):
mock_custom = AsyncMock()
core._custom_objects_client = mock_custom
core._config_loaded = True
result = await core._create_volume_snapshot("pvc1", "snap1")
assert result.success is True
assert "snap1" in result.message
mock_custom.create_namespaced_custom_object.assert_called_once()
async def test_failure(self, core: K7Core):
from kubernetes_asyncio.client.exceptions import ApiException
mock_custom = AsyncMock()
mock_custom.create_namespaced_custom_object.side_effect = ApiException(status=500, reason="connection refused")
core._custom_objects_client = mock_custom
core._config_loaded = True
result = await core._create_volume_snapshot("pvc1", "snap1")
assert result.success is False
assert "connection refused" in result.error
# --- _wait_for_snapshot_ready ---
class TestWaitForSnapshotReady:
async def test_immediately_ready(self, core: K7Core):
mock_custom = AsyncMock()
mock_custom.get_namespaced_custom_object.return_value = {"status": {"readyToUse": True}}
core._custom_objects_client = mock_custom
core._config_loaded = True
result = await core._wait_for_snapshot_ready("snap1", timeout=5)
assert result.success is True
async def test_ready_after_retry(self, core: K7Core):
mock_custom = AsyncMock()
mock_custom.get_namespaced_custom_object.side_effect = [
{"status": {"readyToUse": False}},
{"status": {"readyToUse": True}},
]
core._custom_objects_client = mock_custom
core._config_loaded = True
with patch("k7.core.core.asyncio.sleep", new_callable=AsyncMock):
result = await core._wait_for_snapshot_ready("snap1", timeout=60)
assert result.success is True
async def test_timeout(self, core: K7Core):
mock_custom = AsyncMock()
mock_custom.get_namespaced_custom_object.return_value = {"status": {"readyToUse": False}}
core._custom_objects_client = mock_custom
core._config_loaded = True
elapsed = 0.0
def advancing_time():
nonlocal elapsed
elapsed += 1.0
return elapsed
with (
patch("k7.core.core.time.time", side_effect=advancing_time),
patch("k7.core.core.asyncio.sleep", new_callable=AsyncMock),
):
result = await core._wait_for_snapshot_ready("snap1", timeout=2)
assert result.success is False
assert "not ready" in result.error
# --- _get_registry_image_config ---
def _mock_httpx_response(data: dict):
"""Build a MagicMock that behaves like an httpx.Response (sync .json()/.raise_for_status())."""
resp = MagicMock()
resp.json.return_value = data
resp.raise_for_status.return_value = None
return resp
def _patch_httpx(get_fn):
"""Context manager that patches httpx.AsyncClient with a mock whose .get is `get_fn`."""
p = patch("k7.core.core.httpx.AsyncClient")
mock_cls = p.start()
mock_client = AsyncMock()
mock_client.get = get_fn
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
return p
class TestGetRegistryImageConfig:
@pytest.fixture(autouse=True)
def _public_dns(self, monkeypatch):
"""Keep SSRF allowlist checks offline-friendly in unit tests."""
monkeypatch.setattr(
"k7.core.core.socket.getaddrinfo",
lambda *a, **k: [(None, None, None, None, ("1.1.1.1", 0))],
)
async def test_simple_manifest(self, core: K7Core):
"""Non-Docker-Hub registry with a direct manifest (not a list)."""
manifest = {
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": "sha256:cfgabc"},
}
image_config = {"config": {"Entrypoint": ["/start"]}}
async def mock_get(url, **kwargs):
if "/manifests/" in url:
return _mock_httpx_response(manifest)
return _mock_httpx_response(image_config)
p = _patch_httpx(mock_get)
try:
result = await core._get_registry_image_config("ghcr.io/org/repo:v1")
finally:
p.stop()
assert result == image_config
async def test_dockerhub_token_auth(self, core: K7Core):
"""Docker Hub images trigger a token fetch."""
token_resp_data = {"token": "test-token"}
manifest = {
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": "sha256:cfgabc"},
}
image_config = {"config": {"Cmd": ["/bin/sh"]}}
calls: list[str] = []
async def mock_get(url, **kwargs):
calls.append(url)
if "auth.docker.io" in url:
return _mock_httpx_response(token_resp_data)
if "/manifests/" in url:
return _mock_httpx_response(manifest)
return _mock_httpx_response(image_config)
p = _patch_httpx(mock_get)
try:
result = await core._get_registry_image_config("alpine:3.21")
finally:
p.stop()
assert result == image_config
assert any("auth.docker.io" in c for c in calls)
async def test_manifest_list_picks_amd64(self, core: K7Core):
"""Manifest index resolves to linux/amd64 sub-manifest."""
index = {
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{"digest": "sha256:arm", "platform": {"os": "linux", "architecture": "arm64"}},
{"digest": "sha256:amd", "platform": {"os": "linux", "architecture": "amd64"}},
],
}
inner_manifest = {
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": "sha256:cfgabc"},
}
image_config = {"config": {"Entrypoint": ["/ep"]}}
async def mock_get(url, **kwargs):
if "/manifests/latest" in url:
return _mock_httpx_response(index)
if "/manifests/sha256:amd" in url:
return _mock_httpx_response(inner_manifest)
return _mock_httpx_response(image_config)
p = _patch_httpx(mock_get)
try:
result = await core._get_registry_image_config("ghcr.io/org/repo:latest")
finally:
p.stop()
assert result == image_config
async def test_no_amd64_raises(self, core: K7Core):
"""Manifest index with no linux/amd64 raises ValueError."""
index = {
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{"digest": "sha256:arm", "platform": {"os": "linux", "architecture": "arm64"}},
],
}
async def mock_get(url, **kwargs):
return _mock_httpx_response(index)
import pytest as _pytest
p = _patch_httpx(mock_get)
try:
with _pytest.raises(ValueError, match="No linux/amd64"):
await core._get_registry_image_config("ghcr.io/org/repo:latest")
finally:
p.stop()
async def test_no_config_digest_raises(self, core: K7Core):
"""Manifest with no config digest raises ValueError."""
manifest = {
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {},
}
async def mock_get(url, **kwargs):
return _mock_httpx_response(manifest)
import pytest as _pytest
p = _patch_httpx(mock_get)
try:
with _pytest.raises(ValueError, match="No config digest"):
await core._get_registry_image_config("ghcr.io/org/repo:v1")
finally:
p.stop()