365 lines
12 KiB
Python
365 lines
12 KiB
Python
"""Behavioral tests for the exact protected-run release artifact payload."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from scripts import package_mcp_hub_release_artifact as packager
|
|
|
|
SERVICES = (
|
|
"connector-appops",
|
|
"connector-gitea",
|
|
"connector-ingestion",
|
|
"connector-knowledge",
|
|
"connector-mobilityops",
|
|
"connector-projectbrain",
|
|
"connector-stitch",
|
|
"connector-unraid",
|
|
"contextforge",
|
|
"control-api",
|
|
"dataplane-router",
|
|
"docker-read-broker",
|
|
"ingestion-clamd",
|
|
"postgres",
|
|
"redis",
|
|
"web",
|
|
"worker",
|
|
)
|
|
REPORT_NAMES = {
|
|
"trivy-db-metadata.json",
|
|
*(
|
|
name
|
|
for service in SERVICES
|
|
for name in (f"vulnerability-{service}.json", f"secret-{service}.json")
|
|
),
|
|
}
|
|
REVISION = "a" * 40
|
|
TREE = "b" * 40
|
|
RUN_CREATED_AT = "2026-10-03T10:11:12.123Z"
|
|
|
|
|
|
def _json_bytes(value: object) -> bytes:
|
|
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
|
|
|
|
def _fixture(tmp_path: Path) -> tuple[dict[str, object], dict[str, dict[str, str]], Path, Path]:
|
|
image_tar = tmp_path / "inputs" / "images.tar"
|
|
report_dir = tmp_path / "inputs" / "reports"
|
|
report_dir.mkdir(parents=True)
|
|
image_tar.parent.mkdir(parents=True, exist_ok=True)
|
|
image_tar.write_bytes(b"fixture-docker-save-archive\n")
|
|
images: dict[str, dict[str, str]] = {}
|
|
report_time = "2026-10-03T10:12:00Z"
|
|
for index, service in enumerate(SERVICES, start=1):
|
|
image_id = f"sha256:{index:064x}"
|
|
image_ref = f"registry.example/itworx/{service}:protected-main@sha256:{index:064x}"
|
|
images[service] = {
|
|
"imageRef": image_ref,
|
|
"imageId": image_id,
|
|
"archiveRef": f"registry.example/itworx/{service}:archive-{REVISION[:12]}",
|
|
}
|
|
vulnerability = {
|
|
"SchemaVersion": 2,
|
|
"CreatedAt": report_time,
|
|
"Trivy": {"Version": "0.73.0"},
|
|
"Metadata": {
|
|
"ImageID": image_id,
|
|
"ImageConfig": {"os": "linux", "architecture": "amd64"},
|
|
},
|
|
"Results": [
|
|
{
|
|
"Packages": [{"Name": "fixture-package", "Version": "1.0"}],
|
|
"Vulnerabilities": [],
|
|
}
|
|
],
|
|
}
|
|
secret = {
|
|
"ImageID": image_id,
|
|
"CreatedAt": report_time,
|
|
"TrivyVersion": "0.73.0",
|
|
"FindingCount": 0,
|
|
"SensitiveFindingDetailsPersisted": False,
|
|
"Results": [{"FindingCount": 0}],
|
|
}
|
|
(report_dir / f"vulnerability-{service}.json").write_bytes(_json_bytes(vulnerability))
|
|
(report_dir / f"secret-{service}.json").write_bytes(_json_bytes(secret))
|
|
database = {
|
|
"UpdatedAt": report_time,
|
|
"DownloadedAt": report_time,
|
|
"Version": 2,
|
|
"NextUpdate": "2026-10-04T10:12:00Z",
|
|
}
|
|
(report_dir / "trivy-db-metadata.json").write_bytes(_json_bytes(database))
|
|
build = {
|
|
"id": REVISION[:12],
|
|
"repository": "jens/itworx_mcp_hub",
|
|
"revision": REVISION,
|
|
"tree": TREE,
|
|
"treeDigest": hashlib.sha256(f"git-tree:{TREE}".encode()).hexdigest(),
|
|
"builtAt": RUN_CREATED_AT,
|
|
}
|
|
context: dict[str, object] = {
|
|
"repository": "jens/itworx_mcp_hub",
|
|
"revision": REVISION,
|
|
"tree": TREE,
|
|
"workflowPath": ".gitea/workflows/managed-validation.yml",
|
|
"workflowRunId": 2468,
|
|
"workflowRunCreatedAt": RUN_CREATED_AT,
|
|
"artifactName": f"hub-release-candidate-{REVISION}",
|
|
"build": build,
|
|
"sourceDateEpoch": "1791022272",
|
|
}
|
|
return context, images, image_tar, report_dir
|
|
|
|
|
|
def _package(
|
|
output_dir: Path,
|
|
context: dict[str, object],
|
|
images: dict[str, dict[str, str]],
|
|
image_tar: Path,
|
|
report_dir: Path,
|
|
) -> dict[str, object]:
|
|
return packager.package_candidate_artifact(
|
|
output_dir=output_dir,
|
|
manifest_context=context,
|
|
images=images,
|
|
image_tar_path=image_tar,
|
|
report_dir=report_dir,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _bind_fixture_to_test_git(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
packager,
|
|
"_git_source_identity",
|
|
lambda: (REVISION, TREE, "1791022272"),
|
|
)
|
|
|
|
|
|
def test_package_contains_exact_importer_payload_and_binds_original_bytes(tmp_path: Path) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
output = tmp_path / "artifact"
|
|
|
|
returned_manifest = _package(output, context, images, image_tar, report_dir)
|
|
|
|
expected_names = {"candidate-manifest.json", "images.tar", *REPORT_NAMES}
|
|
assert {path.name for path in output.iterdir()} == expected_names
|
|
assert all(path.is_file() and not path.is_symlink() for path in output.iterdir())
|
|
manifest_bytes = (output / "candidate-manifest.json").read_bytes()
|
|
manifest = json.loads(manifest_bytes)
|
|
tar_bytes = image_tar.read_bytes()
|
|
assert manifest == returned_manifest
|
|
assert set(manifest) == {
|
|
"schema",
|
|
"repository",
|
|
"sourceRevision",
|
|
"sourceTree",
|
|
"workflowPath",
|
|
"workflowRunId",
|
|
"artifactName",
|
|
"build",
|
|
"sourceDateEpoch",
|
|
"dockerTarBytes",
|
|
"dockerTarSha256",
|
|
"images",
|
|
"reports",
|
|
}
|
|
assert manifest["schema"] == "itworx.mcp-hub-image-artifact/v1"
|
|
assert manifest["repository"] == context["repository"]
|
|
assert manifest["sourceRevision"] == context["revision"]
|
|
assert manifest["sourceTree"] == context["tree"]
|
|
assert manifest["workflowPath"] == context["workflowPath"]
|
|
assert manifest["workflowRunId"] == context["workflowRunId"]
|
|
assert manifest["artifactName"] == context["artifactName"]
|
|
assert manifest["build"] == context["build"]
|
|
assert manifest["sourceDateEpoch"] == context["sourceDateEpoch"]
|
|
assert manifest["dockerTarBytes"] == len(tar_bytes)
|
|
assert manifest["dockerTarSha256"] == hashlib.sha256(tar_bytes).hexdigest()
|
|
assert manifest["images"] == images
|
|
assert manifest["reports"] == {
|
|
name: {
|
|
"sha256": hashlib.sha256((report_dir / name).read_bytes()).hexdigest(),
|
|
"bytes": (report_dir / name).stat().st_size,
|
|
}
|
|
for name in sorted(REPORT_NAMES)
|
|
}
|
|
assert (output / "images.tar").read_bytes() == tar_bytes
|
|
for name in REPORT_NAMES:
|
|
assert (output / name).read_bytes() == (report_dir / name).read_bytes()
|
|
|
|
|
|
def test_canonicalizes_the_exact_workflow_run_timestamp_to_importer_format(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
context["workflowRunCreatedAt"] = "2026-10-03T10:11:12Z"
|
|
build = dict(context["build"])
|
|
build["builtAt"] = "2026-10-03T10:11:12.000Z"
|
|
context["build"] = build
|
|
|
|
manifest = _package(tmp_path / "artifact", context, images, image_tar, report_dir)
|
|
|
|
assert manifest["build"]["builtAt"] == "2026-10-03T10:11:12.000Z"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutation", "value"),
|
|
[
|
|
("revision", "c" * 40),
|
|
("tree", "d" * 40),
|
|
("run_id", 0),
|
|
("run_time", "2026-10-03T10:11:12Z"),
|
|
("artifact_name", "hub-release-candidate-wrong"),
|
|
("epoch", "01791022272"),
|
|
],
|
|
)
|
|
def test_rejects_unbound_frozen_context_without_publishing(
|
|
tmp_path: Path, mutation: str, value: object
|
|
) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
context = copy.deepcopy(context)
|
|
if mutation == "revision":
|
|
context["revision"] = value
|
|
elif mutation == "tree":
|
|
context["tree"] = value
|
|
elif mutation == "run_id":
|
|
context["workflowRunId"] = value
|
|
elif mutation == "run_time":
|
|
context["workflowRunCreatedAt"] = value
|
|
elif mutation == "artifact_name":
|
|
context["artifactName"] = value
|
|
else:
|
|
context["sourceDateEpoch"] = value
|
|
output = tmp_path / "artifact"
|
|
|
|
with pytest.raises(packager.ArtifactRejected):
|
|
_package(output, context, images, image_tar, report_dir)
|
|
|
|
assert not output.exists()
|
|
assert not list(tmp_path.glob(".hub-release-artifact-*"))
|
|
|
|
|
|
@pytest.mark.parametrize("report_change", ["missing", "extra", "oversized", "wrong_image"])
|
|
def test_rejects_invalid_fixed_report_inventory_without_partial_output(
|
|
tmp_path: Path, report_change: str
|
|
) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
if report_change == "missing":
|
|
(report_dir / "secret-worker.json").unlink()
|
|
elif report_change == "extra":
|
|
(report_dir / "unlisted.json").write_text("{}", encoding="utf-8")
|
|
elif report_change == "oversized":
|
|
(report_dir / "secret-worker.json").write_bytes(b" " * (2 * 1024**2 + 1))
|
|
else:
|
|
data = json.loads((report_dir / "vulnerability-worker.json").read_text(encoding="utf-8"))
|
|
data["Metadata"]["ImageID"] = "sha256:" + "f" * 64
|
|
(report_dir / "vulnerability-worker.json").write_bytes(_json_bytes(data))
|
|
output = tmp_path / "artifact"
|
|
|
|
with pytest.raises(packager.ArtifactRejected):
|
|
_package(output, context, images, image_tar, report_dir)
|
|
|
|
assert not output.exists()
|
|
|
|
|
|
def test_rejects_secret_findings_instead_of_redacting_or_rewriting_them(tmp_path: Path) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
secret_path = report_dir / "secret-worker.json"
|
|
secret = json.loads(secret_path.read_text(encoding="utf-8"))
|
|
secret["FindingCount"] = 1
|
|
secret["SensitiveFindingDetailsPersisted"] = True
|
|
secret_path.write_bytes(_json_bytes(secret))
|
|
original = secret_path.read_bytes()
|
|
output = tmp_path / "artifact"
|
|
|
|
with pytest.raises(packager.ArtifactRejected):
|
|
_package(output, context, images, image_tar, report_dir)
|
|
|
|
assert not output.exists()
|
|
assert secret_path.read_bytes() == original
|
|
|
|
|
|
def test_rejects_output_collision_without_overwriting_existing_files(tmp_path: Path) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
output = tmp_path / "artifact"
|
|
output.mkdir()
|
|
marker = output / "keep.txt"
|
|
marker.write_text("preserve", encoding="utf-8")
|
|
|
|
with pytest.raises(packager.ArtifactRejected):
|
|
_package(output, context, images, image_tar, report_dir)
|
|
|
|
assert marker.read_text(encoding="utf-8") == "preserve"
|
|
assert {path.name for path in output.iterdir()} == {"keep.txt"}
|
|
|
|
|
|
def test_rejects_output_directory_created_at_atomic_publish_boundary(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
output = tmp_path / "artifact"
|
|
original_publish = packager._rename_noreplace
|
|
|
|
def create_collision_then_publish(source: Path, destination: Path) -> None:
|
|
destination.mkdir()
|
|
original_publish(source, destination)
|
|
|
|
monkeypatch.setattr(packager, "_rename_noreplace", create_collision_then_publish)
|
|
|
|
with pytest.raises(packager.ArtifactRejected):
|
|
_package(output, context, images, image_tar, report_dir)
|
|
|
|
assert output.is_dir()
|
|
assert list(output.iterdir()) == []
|
|
assert not list(tmp_path.glob(".hub-release-artifact-*"))
|
|
|
|
|
|
def test_rejects_wrong_image_inventory_without_partial_output(tmp_path: Path) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
images.pop("worker")
|
|
output = tmp_path / "artifact"
|
|
|
|
with pytest.raises(packager.ArtifactRejected):
|
|
_package(output, context, images, image_tar, report_dir)
|
|
|
|
assert not output.exists()
|
|
|
|
|
|
def test_rejects_source_identity_that_does_not_match_checkout(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
monkeypatch.setattr(
|
|
packager,
|
|
"_git_source_identity",
|
|
lambda: ("f" * 40, TREE, "1791022272"),
|
|
)
|
|
output = tmp_path / "artifact"
|
|
|
|
with pytest.raises(packager.ArtifactRejected):
|
|
_package(output, context, images, image_tar, report_dir)
|
|
|
|
assert not output.exists()
|
|
|
|
|
|
def test_rejects_hardlinked_report_without_publishing(tmp_path: Path) -> None:
|
|
context, images, image_tar, report_dir = _fixture(tmp_path)
|
|
secret_path = report_dir / "secret-worker.json"
|
|
secret_path.unlink()
|
|
try:
|
|
secret_path.hardlink_to(report_dir / "secret-web.json")
|
|
except OSError as exc:
|
|
pytest.skip(f"filesystem does not support the hard-link fixture: {exc}")
|
|
output = tmp_path / "artifact"
|
|
|
|
with pytest.raises(packager.ArtifactRejected):
|
|
_package(output, context, images, image_tar, report_dir)
|
|
|
|
assert not output.exists()
|