Files
ITWorx_MCP_Hub-Public/tests/test_mcp_hub_release_images.py
T

620 lines
24 KiB
Python

from __future__ import annotations
import hashlib
import io
import json
import os
import subprocess
import sys
import tarfile
from datetime import UTC, datetime
from pathlib import Path
import pytest
from scripts import build_mcp_hub_release_images as producer
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "build_mcp_hub_release_images.py"
@pytest.mark.parametrize(
"failure,category,code",
[("exit", "nonzero_exit", 7), ("timeout", "timeout", None), ("os", "exec_error", None)],
)
def test_fixed_stage_failure_reports_only_closed_phase_and_category(
tmp_path, capsys, failure, category, code
):
marker = "fixture-sensitive-output-must-not-escape"
def runner(command, **kwargs):
assert kwargs["stdout"] == subprocess.DEVNULL
assert kwargs["stderr"] == subprocess.DEVNULL
if failure == "timeout":
raise subprocess.TimeoutExpired([marker], 1, output=marker, stderr=marker)
if failure == "os":
raise OSError(marker)
return subprocess.CompletedProcess(command, code, stdout=marker, stderr=marker)
env = {
name: "fixture"
for name in (
"GITEA_COMMIT_SHA",
"APPOPS_SOURCE_TREE",
"APPOPS_BUILD_ID",
"APPOPS_BUILD_DATE",
"SOURCE_DATE_EPOCH",
)
}
with pytest.raises(producer.BuildRejected) as error:
producer._build_contextforge(tmp_path, env, runner=runner)
message = str(error.value)
assert "stage=contextforge_build" in message
assert f"category={category}" in message
assert (f"exitCode={code}" in message) if code is not None else "exitCode=" not in message
assert marker not in message
assert marker not in "".join(capsys.readouterr())
def test_unknown_stage_is_rejected_without_running_or_echoing_it(tmp_path):
def runner(*args, **kwargs):
pytest.fail("unknown stage must not execute")
with pytest.raises(producer.BuildRejected, match="^fixed release stage identifier is invalid$"):
producer._execute(
["fixture"],
env={},
cwd=tmp_path,
timeout=1,
stage="fixture-sensitive-stage",
runner=runner,
)
@pytest.mark.parametrize("code", [True, "fixture-sensitive-exit", 2**40])
def test_invalid_exit_code_is_not_echoed(tmp_path, code):
def runner(command, **kwargs):
return subprocess.CompletedProcess(command, code)
with pytest.raises(producer.BuildRejected, match="category=invalid_exit_code$"):
producer._execute(
["fixture"], env={}, cwd=tmp_path, timeout=1, stage="compose_local_build", runner=runner
)
def test_protected_metadata_uses_fixed_https_without_redirecting_credentials(monkeypatch):
class Opener:
def open(self, request, timeout):
assert request.full_url == (
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs/123"
)
assert request.get_header("Authorization") == "token fixture-token"
assert timeout == 20
return io.BytesIO(b'{"id":123}')
def build_opener(*handlers):
assert handlers[0].proxies == {}
assert (
handlers[1].redirect_request(None, None, 302, "redirect", {}, "https://other.test")
is None
)
return Opener()
monkeypatch.setattr(producer.urllib.request, "build_opener", build_opener)
assert producer._api_json("fixture-token", "/repos/jens/itworx_mcp_hub/actions/runs/123") == {
"id": 123
}
def _write_archive_fixture(path: Path, members: dict[str, bytes | None]) -> None:
with tarfile.open(path, "w:", format=tarfile.USTAR_FORMAT) as archive:
for name, payload in members.items():
info = tarfile.TarInfo(
name + ("/" if payload is None and not name.endswith("/") else "")
)
if payload is None:
info.type = tarfile.DIRTYPE
info.mode = 0o755
archive.addfile(info)
else:
info.size = len(payload)
archive.addfile(info, io.BytesIO(payload))
def _docker29_oci_fixture(
tmp_path: Path,
) -> tuple[Path, dict[str, bytes | None], dict[str, dict[str, str]], dict[str, str]]:
tmp_path.mkdir(parents=True, exist_ok=True)
revision = "a" * 40
metadata = {
"revision": revision,
"tree": "b" * 40,
"buildId": revision[:12],
"builtAt": "2026-10-03T12:34:56.000Z",
"sourceDateEpoch": "1791027296",
}
layer_file = io.BytesIO()
with tarfile.open(fileobj=layer_file, mode="w:", format=tarfile.USTAR_FORMAT) as layer_archive:
content = b"synthetic Docker 29 image layer"
item = tarfile.TarInfo("fixture.txt")
item.size = len(content)
layer_archive.addfile(item, io.BytesIO(content))
layer = layer_file.getvalue()
layer_digest = "sha256:" + hashlib.sha256(layer).hexdigest()
layer_name = "blobs/sha256/" + layer_digest.split(":", 1)[1]
members: dict[str, bytes | None] = {"blobs": None, "blobs/sha256": None, layer_name: layer}
images: dict[str, dict[str, str]] = {}
docker_manifest: list[dict[str, object]] = []
index_descriptors: list[dict[str, object]] = []
for service in producer.ALL_SERVICES:
if service in producer.LOCAL_BUILD_SERVICES:
reference = f"{producer.IMAGE_REPOSITORIES[service]}:{revision}"
elif service == "contextforge":
reference = f"itworx-mcp-hub/contextforge-schema:{revision}"
elif service == "connector-ingestion":
reference = f"{producer.IMAGE_PREFIX}/connector-ingestion:{revision}-run-123-attempt-1"
else:
reference = producer.EXTERNAL_IMAGES[service][1]
labels: dict[str, str] = {}
if service not in producer.EXTERNAL_IMAGES:
for key, label in producer.OCI_LABELS.items():
labels[key] = (
"https://gitea.example.com/jens/itworx-mcp-hub"
if key == "org.opencontainers.image.source"
else metadata[label]
)
config = {
"architecture": "amd64",
"os": "linux",
"config": {"User": "10001:10001", "Labels": labels, "Cmd": [service]},
"rootfs": {"type": "layers", "diff_ids": [layer_digest]},
}
config_bytes = json.dumps(config, sort_keys=True, separators=(",", ":")).encode()
image_id = "sha256:" + hashlib.sha256(config_bytes).hexdigest()
config_name = f"blobs/sha256/{image_id.split(':', 1)[1]}"
members[config_name] = config_bytes
images[service] = {"imageId": image_id, "archiveRef": reference, "imageRef": reference}
docker_manifest.append(
{
"Config": config_name,
"RepoTags": [reference],
"Layers": [layer_name],
"LayerSources": {
layer_digest: {
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"size": len(layer),
"digest": layer_digest,
}
},
}
)
image_manifest = {
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": image_id,
"size": len(config_bytes),
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": layer_digest,
"size": len(layer),
}
],
}
image_manifest_bytes = json.dumps(
image_manifest, sort_keys=True, separators=(",", ":")
).encode()
manifest_digest = hashlib.sha256(image_manifest_bytes).hexdigest()
members[f"blobs/sha256/{manifest_digest}"] = image_manifest_bytes
index_descriptors.append(
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:" + manifest_digest,
"size": len(image_manifest_bytes),
"annotations": {"org.opencontainers.image.ref.name": reference},
"platform": {"os": "linux", "architecture": "amd64"},
}
)
members["oci-layout"] = b'{"imageLayoutVersion":"1.0.0"}'
members["index.json"] = json.dumps(
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": index_descriptors,
},
sort_keys=True,
separators=(",", ":"),
).encode()
members["manifest.json"] = json.dumps(docker_manifest, separators=(",", ":")).encode()
raw = tmp_path / "docker29-oci-save.tar"
_write_archive_fixture(raw, members)
return raw, members, images, metadata
def test_missing_registry_credential_is_rejected_before_building(tmp_path: Path) -> None:
env = {
key: value
for key, value in os.environ.items()
if key not in {"GITEA_TOKEN", "HUB_RELEASE_REGISTRY_TOKEN"}
}
env.update(
{
"GITEA_TOKEN": "metadata-token-test-value",
"GITHUB_EVENT_NAME": "push",
"GITHUB_REF": "refs/heads/main",
"GITHUB_REPOSITORY": "Jens/ITWorx_MCP_Hub",
}
)
result = subprocess.run( # noqa: S603 -- executes only this repository's fixed producer script.
[
sys.executable,
str(SCRIPT),
"--revision",
"a" * 40,
"--run-id",
"123",
"--output",
str(tmp_path / "candidate"),
],
check=False,
capture_output=True,
text=True,
env=env,
)
assert result.returncode != 0
assert "HUB_RELEASE_REGISTRY_TOKEN" in result.stderr
assert "metadata-token-test-value" not in result.stdout + result.stderr
assert not (tmp_path / "candidate").exists()
def test_run_binding_requires_the_one_successful_full_gate() -> None:
revision = "a" * 40
run_id = 123
run = {
"id": run_id,
"path": producer.WORKFLOW_PATH,
"event": "push",
"head_branch": "main",
"head_sha": revision,
"repository": {"full_name": producer.DISPLAY_REPOSITORY},
"head_repository": {"full_name": producer.DISPLAY_REPOSITORY},
"pull_requests": [],
"status": "in_progress",
"run_attempt": 1,
"created_at": "2026-10-03T12:34:56Z",
}
jobs = {
"jobs": [
{
"name": "full",
"run_id": run_id,
"run_attempt": 1,
"head_sha": revision,
"head_branch": "main",
"status": "completed",
"conclusion": "success",
}
]
}
branch = {"commit": {"id": revision}}
result = producer._validate_run(run, jobs, branch, revision=revision, run_id=run_id)
assert result["workflowRunCreatedAt"] == "2026-10-03T12:34:56.000Z"
assert result["runAttempt"] == 1
with pytest.raises(producer.BuildRejected):
producer._validate_run(
run, {"jobs": jobs["jobs"] * 2}, branch, revision=revision, run_id=run_id
)
bad_job = {"jobs": [dict(jobs["jobs"][0], conclusion="failure")]}
with pytest.raises(producer.BuildRejected):
producer._validate_run(run, bad_job, branch, revision=revision, run_id=run_id)
def test_cli_environment_rejects_docker_endpoint_overrides() -> None:
revision = "b" * 40
env = {
"GITHUB_EVENT_NAME": "push",
"GITHUB_REF": "refs/heads/main",
"GITHUB_REPOSITORY": producer.DISPLAY_REPOSITORY,
"GITHUB_SHA": revision,
"GITHUB_RUN_ID": "5",
"DOCKER_HOST": "tcp://attacker.invalid:2375",
}
with pytest.raises(producer.BuildRejected):
producer._validate_cli_environment(revision, 5, env)
@pytest.mark.parametrize(
("buildx", "compose", "accepted"),
[
("github.com/docker/buildx v0.37.2", "v5.5.1", True),
("github.com/docker/buildx 0.37.2", "5.5.1", True),
("github.com/docker/buildx v0.37.20", "v5.5.1", False),
("github.com/docker/buildx v0.37.2", "v5.5.10", False),
],
)
def test_job_daemon_requires_exact_buildx_and_compose_versions(
buildx: str,
compose: str,
accepted: bool,
) -> None:
def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
if command[1:3] == ["info", "--format"]:
output = 'daemon-identity-012345|29.8.2|overlay2|["name=rootless"]'
elif command[1:] == ["buildx", "version"]:
output = buildx
elif command[1:] == ["compose", "version", "--short"]:
output = compose
else:
pytest.fail("version admission invoked an unexpected command")
return subprocess.CompletedProcess(command, 0, output, "")
if accepted:
assert producer._check_job_daemon({}, Path.cwd(), runner=runner) == "daemon-identity-012345"
else:
with pytest.raises(producer.BuildRejected):
producer._check_job_daemon({}, Path.cwd(), runner=runner)
def test_fixed_image_contract_is_17_services_with_12_compose_builds() -> None:
revision = "c" * 40
services = {
service: {"image": f"{repository}:{revision}"}
for service, repository in producer.IMAGE_REPOSITORIES.items()
}
services["contextforge"] = {"image": f"itworx-mcp-hub/contextforge-schema:{revision}"}
services["connector-ingestion"] = {
"image": f"{producer.IMAGE_PREFIX}/connector-ingestion:{revision}-pending@sha256:{'0' * 64}"
}
for service, (image, _archive) in producer.EXTERNAL_IMAGES.items():
services[service] = {"image": image}
model = {
"services": {
service: {
**definition,
**({"build": {"context": "."}} if service in producer.LOCAL_BUILD_SERVICES else {}),
}
for service, definition in services.items()
}
}
assert producer._validate_local_refs(model, revision) == {
service: services[service]["image"] for service in producer.LOCAL_BUILD_SERVICES
}
model["services"]["connector-ingestion"]["build"] = {"context": "."}
with pytest.raises(producer.BuildRejected):
producer._validate_compose_model(model)
wrong_ref = {"services": {name: dict(row) for name, row in model["services"].items()}}
wrong_ref["services"]["control-api"]["image"] = "attacker.invalid/control:mutable"
with pytest.raises(producer.BuildRejected):
producer._validate_local_refs(wrong_ref, revision)
def test_manifest_descriptor_is_strict_and_digest_bound() -> None:
digest = "sha256:" + "d" * 64
descriptor = {
"digest": digest,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"size": 120,
}
assert producer._parse_manifest_descriptor(json.dumps(descriptor)) == digest
with pytest.raises(producer.BuildRejected):
producer._parse_manifest_descriptor(json.dumps({**descriptor, "extra": "x"}))
with pytest.raises(producer.BuildRejected):
producer._parse_manifest_descriptor(json.dumps({**descriptor, "digest": "sha256:bad"}))
def test_docker29_oci_archive_normalizes_index_blobs_to_exact_ustar_importer_shape(
tmp_path: Path,
) -> None:
raw, _members, images, metadata = _docker29_oci_fixture(tmp_path)
normalized = tmp_path / "normalized.tar"
producer._normalize_docker_archive(raw, normalized, images, metadata=metadata)
with tarfile.open(normalized, "r:") as archive:
members = archive.getmembers()
names = {member.name.rstrip("/") for member in members}
image_ids = {row["imageId"].split(":", 1)[1] for row in images.values()}
manifest_stream = archive.extractfile("manifest.json")
assert manifest_stream is not None
manifest_rows = json.loads(manifest_stream.read())
expected_names = {"manifest.json", *(f"{image_id}.json" for image_id in image_ids)}
for row in manifest_rows:
expected_names.update(layer[:-10] for layer in row["Layers"])
expected_names.update(row["Layers"])
assert names == expected_names
assert len(manifest_rows) == len(producer.ALL_SERVICES)
assert {row["RepoTags"][0] for row in manifest_rows} == {
row["archiveRef"] for row in images.values()
}
assert all(member.type in {tarfile.REGTYPE, tarfile.DIRTYPE} for member in members)
assert all(member.pax_headers == {} for member in members)
with normalized.open("rb") as source:
assert source.read(512)[257:263] == b"ustar\x00"
if os.name != "nt":
assert normalized.stat().st_mode & 0o777 == 0o600
def test_docker_oci_index_can_canonicalize_external_reference_aliases(tmp_path: Path) -> None:
raw, members, images, metadata = _docker29_oci_fixture(tmp_path)
index = json.loads(members["index.json"])
postgres_ref = images["postgres"]["archiveRef"]
descriptor = next(
item
for item in index["manifests"]
if item["annotations"]["org.opencontainers.image.ref.name"] == postgres_ref
)
descriptor["annotations"]["org.opencontainers.image.ref.name"] = (
"docker.io/library/" + postgres_ref
)
members["index.json"] = json.dumps(index, sort_keys=True, separators=(",", ":")).encode()
_write_archive_fixture(raw, members)
normalized = tmp_path / "canonical-alias-normalized.tar"
producer._normalize_docker_archive(raw, normalized, images, metadata=metadata)
assert normalized.is_file()
def test_docker29_oci_normalizer_rejects_unreferenced_blobs_and_config_mismatch(
tmp_path: Path,
) -> None:
raw, members, images, metadata = _docker29_oci_fixture(tmp_path)
orphan_name = "blobs/sha256/" + "f" * 64
members[orphan_name] = b"unreferenced"
orphan_archive = tmp_path / "orphan.tar"
_write_archive_fixture(orphan_archive, members)
with pytest.raises(producer.BuildRejected):
producer._normalize_docker_archive(
orphan_archive,
tmp_path / "orphan-normalized.tar",
images,
metadata=metadata,
)
raw, _members, images, metadata = _docker29_oci_fixture(tmp_path / "mismatch")
images["web"]["imageId"] = "sha256:" + "0" * 64
with pytest.raises(producer.BuildRejected):
producer._normalize_docker_archive(
raw,
tmp_path / "mismatch-normalized.tar",
images,
metadata=metadata,
)
def test_secret_scan_summary_keeps_finding_details_out_of_persisted_json() -> None:
image_id = "sha256:" + "e" * 64
raw_secret = "super-secret-value-for-test" # noqa: S105 -- synthetic redaction fixture.
raw = json.dumps({"Metadata": {"ImageID": image_id}, "Results": [{"Secrets": []}]}).encode()
summary = producer._secret_summary(raw, image_id)
encoded = json.dumps(summary)
assert summary["FindingCount"] == 0
assert summary["SensitiveFindingDetailsPersisted"] is False
assert raw_secret not in encoded
finding = json.dumps(
{
"Metadata": {"ImageID": image_id},
"Results": [{"Secrets": [{"Match": raw_secret, "RuleID": "test"}]}],
}
).encode()
with pytest.raises(producer.BuildRejected):
producer._secret_summary(finding, image_id)
@pytest.mark.skipif(
os.name != "posix", reason="bounded scanner stream requires POSIX process groups"
)
def test_scanner_stdout_bound_does_not_cap_or_capture_cache_files(tmp_path: Path) -> None:
def fake_scanner(command: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
assert "preexec_fn" not in kwargs
assert kwargs.get("stdout") == subprocess.PIPE
code = (
"from pathlib import Path; import os; "
"Path('cache.bin').write_bytes(b'x' * (3 * 1024 * 1024)); "
"os.write(1, b'{\"complete\":true}')"
)
return subprocess.Popen( # noqa: S603 -- executes the fixed synthetic scanner fixture.
[sys.executable, "-c", code],
**kwargs, # type: ignore[arg-type]
)
output = producer._run_scanner_capture(
["fake-trivy"],
env=os.environ,
cwd=tmp_path,
timeout=10,
popen=fake_scanner,
maximum=64,
)
assert output == b'{"complete":true}'
assert (tmp_path / "cache.bin").stat().st_size == 3 * 1024 * 1024
@pytest.mark.skipif(
os.name != "posix", reason="bounded scanner stream requires POSIX process groups"
)
def test_scanner_stdout_over_limit_is_rejected_without_persisting_bytes(tmp_path: Path) -> None:
def fake_scanner(command: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
code = "import os; os.write(1, b'x' * 65)"
return subprocess.Popen( # noqa: S603 -- executes the fixed synthetic scanner fixture.
[sys.executable, "-c", code],
**kwargs, # type: ignore[arg-type]
)
with pytest.raises(producer.BuildRejected):
producer._run_scanner_capture(
["fake-trivy"],
env=os.environ,
cwd=tmp_path,
timeout=10,
popen=fake_scanner,
maximum=64,
)
assert list(tmp_path.iterdir()) == []
def test_ingestion_publication_tag_is_bound_to_authenticated_attempt() -> None:
revision = "d" * 40
first = producer._ingestion_tag(revision, 456, 1)
retry = producer._ingestion_tag(revision, 456, 2)
assert first == f"{producer.IMAGE_PREFIX}/connector-ingestion:{revision}-run-456-attempt-1"
assert retry == f"{producer.IMAGE_PREFIX}/connector-ingestion:{revision}-run-456-attempt-2"
assert first != retry
with pytest.raises(producer.BuildRejected):
producer._ingestion_tag("../" + revision, 456, 1)
def test_registry_absence_probe_accepts_only_the_exact_missing_reference() -> None:
reference = f"{producer.IMAGE_PREFIX}/connector-ingestion:{'e' * 40}-run-7-attempt-1"
def missing(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert reference in command
return subprocess.CompletedProcess(command, 1, "", f"ERROR: {reference}: not found")
producer._registry_tag_absent(reference, {}, Path.cwd(), runner=missing)
def denied(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(command, 1, "", "unauthorized: authentication required")
with pytest.raises(producer.BuildRejected):
producer._registry_tag_absent(reference, {}, Path.cwd(), runner=denied)
def exists(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(command, 0, "{}", "")
with pytest.raises(producer.BuildRejected):
producer._registry_tag_absent(reference, {}, Path.cwd(), runner=exists)
def test_vulnerability_report_requires_all_packages_and_rejects_fixable_critical() -> None:
image_id = "sha256:" + "f" * 64
report = {
"SchemaVersion": 2,
"CreatedAt": datetime.now(UTC).isoformat(),
"Metadata": {"ImageID": image_id, "ImageConfig": {"os": "linux", "architecture": "amd64"}},
"Trivy": {"Version": producer.TRIVY_VERSION},
"Results": [{"Packages": [{"Name": "pkg", "Version": "1.0"}], "Vulnerabilities": []}],
}
producer._validate_vulnerability(report, image_id)
report["Results"][0]["Vulnerabilities"] = [{"Severity": "CRITICAL", "FixedVersion": "2.0"}]
with pytest.raises(producer.BuildRejected):
producer._validate_vulnerability(report, image_id)
report["Results"][0]["Vulnerabilities"] = []
report["Results"][0]["Packages"] = []
with pytest.raises(producer.BuildRejected):
producer._validate_vulnerability(report, image_id)