From f02e6dc3f7cd084af3c6d7bc17eb5c7227b7afac Mon Sep 17 00:00:00 2001 From: Tiara Lena Hock Date: Tue, 12 Aug 2025 16:54:32 +0200 Subject: [PATCH 1/7] Add tests for apt/package_repo_info.py --- tests/apt/test_package_repo_info.py | 140 ++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/apt/test_package_repo_info.py diff --git a/tests/apt/test_package_repo_info.py b/tests/apt/test_package_repo_info.py new file mode 100644 index 00000000..0742e009 --- /dev/null +++ b/tests/apt/test_package_repo_info.py @@ -0,0 +1,140 @@ +from types import SimpleNamespace + +import gardenlinux.apt.package_repo_info as repoinfo + + +class FakeAPTRepo: + """ + Fake replacement for apt_repo.APTTRepository. + + - stores the contructor args for assertions + - exposes `.packages` and `get_packages_by_name(name)` + """ + + def __init__(self, url, dist, components) -> None: + self.url = url + self.dist = dist + self.components = components + # list of objects with .package and .version attributes + self.packages = [] + + def get_packages_by_name(self, name): + return [p for p in self.packages if p.package == name] + + +def test_gardenlinuxrepo_init(monkeypatch): + """ + Test if GardenLinuxRepo creates an internal APTRepo + """ + # Arrange + monkeypatch.setattr(repoinfo, "APTRepository", FakeAPTRepo) + + # Act + gr = repoinfo.GardenLinuxRepo("dist-123") + + # Assert + assert gr.dist == "dist-123" + assert gr.url == "http://packages.gardenlinux.io/gardenlinux" + assert gr.components == ["main"] + # Assert that patch works + assert isinstance(gr.repo, FakeAPTRepo) + # Assert that constructor actually built an internal repo instance + assert gr.repo.url == gr.url + assert gr.repo.dist == gr.dist + assert gr.repo.components == gr.components + + +def test_get_package_version_by_name(monkeypatch): + # Arrange + monkeypatch.setattr(repoinfo, "APTRepository", FakeAPTRepo) + gr = repoinfo.GardenLinuxRepo("d") + # Fake package objects + gr.repo.packages = [ + SimpleNamespace(package="pkg-a", version="1.0"), + SimpleNamespace(package="pkg-b", version="2.0"), + ] # type: ignore + + # Act + result = gr.get_package_version_by_name("pkg-a") + + # Assert + assert result == [("pkg-a", "1.0")] + + +def test_get_packages_versions_returns_all_pairs(monkeypatch): + # Arrange + monkeypatch.setattr(repoinfo, "APTRepository", FakeAPTRepo) + gr = repoinfo.GardenLinuxRepo("d") + gr.repo.packages = [ + SimpleNamespace(package="aa", version="0.1"), + SimpleNamespace(package="bb", version="0.2"), + ] # type: ignore + + # Act + pv = gr.get_packages_versions() + + # Assert + assert pv == [("aa", "0.1"), ("bb", "0.2")] + + +def test_compare_repo_union_returns_all(): + """ + When available_in_both=False, compare_repo returns entries for: + - only names in A + - only names in B + - names in both but with different versions + """ + # Arrange + a = SimpleNamespace(get_packages_versions=lambda: [("a", "1"), ("b", "2")]) + b = SimpleNamespace(get_packages_versions=lambda: [("b", "3"), ("c", "4")]) + + # Act + result = repoinfo.compare_repo(a, b, available_in_both=False) # type: ignore + + # Assert + expected = { + ("a", "1", None), + ("b", "2", "3"), + ("c", None, "4"), + } + assert set(result) == expected + + +def test_compare_repo_intersection_only(): + """ + When available_in_both=True, only intersection names are considered; + differences are only returned if versions differ. + """ + # Arrange (both share 'b' with different versions) + a = SimpleNamespace(get_packages_versions=lambda: [("a", "1"), ("b", "2")]) + b = SimpleNamespace(get_packages_versions=lambda: [("b", "3"), ("c", "4")]) + + # Act + result = repoinfo.compare_repo(a, b, available_in_both=True) # type: ignore + + # Assert + assert set(result) == {("b", "2", "3")} + + +def test_compare_same_returns_empty(): + """ + When both sets are identical, compare_repo should return an empty set. + """ + # Arrange + a = SimpleNamespace(get_packages_versions=lambda: [("a", "1"), ("b", "2")]) + b = SimpleNamespace(get_packages_versions=lambda: [("a", "1"), ("b", "2")]) + + # Act / Assert + assert repoinfo.compare_repo(a, b, available_in_both=False) == [] # type: ignore + + +def test_compare_empty_returns_empty(): + """ + If both sets are empty, compare_repo should return an empty set. + """ + # Arrange + a = SimpleNamespace(get_packages_versions=lambda: []) + b = SimpleNamespace(get_packages_versions=lambda: []) + + # Act / Assert + assert repoinfo.compare_repo(a, b, available_in_both=True) == [] # type: ignore From 8a952796d14fc022b1ae41c96db8140f2415514f Mon Sep 17 00:00:00 2001 From: Tiara Lena Hock Date: Tue, 12 Aug 2025 16:54:53 +0200 Subject: [PATCH 2/7] Add tests for s3/__main__.py --- tests/s3/test_main.py | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/s3/test_main.py diff --git a/tests/s3/test_main.py b/tests/s3/test_main.py new file mode 100644 index 00000000..f7eb6e54 --- /dev/null +++ b/tests/s3/test_main.py @@ -0,0 +1,51 @@ +import sys +from unittest.mock import patch, MagicMock + +import pytest + +import gardenlinux.s3.__main__ as s3m + + +@pytest.mark.parametrize( + "argv,expected_method", + [ + ( + [ + "__main__.py", + "--bucket", + "test-bucket", + "--cname", + "test-cname", + "--path", + "some/path", + "download-artifacts-from-bucket", + ], + "download_to_directory", + ), + ( + [ + "__main__.py", + "--bucket", + "test-bucket", + "--cname", + "test-cname", + "--path", + "some/path", + "upload-artifacts-to-bucket", + ], + "upload_from_directory", + ), + ], +) +def test_main_calls_correct_artifacts(argv, expected_method): + with patch.object(sys, "argv", argv): + with patch.object(s3m, "S3Artifacts") as mock_s3_cls: + mock_instance = MagicMock() + mock_s3_cls.return_value = mock_instance + + s3m.main() + + method = getattr(mock_instance, expected_method) + method.assert_called_once_with("test-cname", "some/path") + + mock_s3_cls.assert_called_once_with("test-bucket") From 3b6e4169353bde901081a1f7a887d82e77a5ce6e Mon Sep 17 00:00:00 2001 From: Tiara Lena Hock Date: Tue, 12 Aug 2025 16:58:12 +0200 Subject: [PATCH 3/7] Make: Bump coverage failure threshhold to 85 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 61c8617f..0aecfa2d 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ test-coverage: install-test $(POETRY) run pytest -k "not kms" --cov=gardenlinux --cov-report=xml tests/ test-coverage-ci: install-test - $(POETRY) run pytest -k "not kms" --cov=gardenlinux --cov-report=xml --cov-fail-under=50 tests/ + $(POETRY) run pytest -k "not kms" --cov=gardenlinux --cov-report=xml --cov-fail-under=85 tests/ test-debug: install-test $(POETRY) run pytest -k "not kms" -vvv -s From 19360e01cd2f1492c46f0746423199399586bd86 Mon Sep 17 00:00:00 2001 From: Tiara Lena Hock Date: Tue, 12 Aug 2025 17:04:56 +0200 Subject: [PATCH 4/7] Add digest file to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b06abbe6..955a202f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ _build +digest # Byte-compiled / optimized / DLL files __pycache__/ From 872a281c892a5d88a237233aa7f8da97f71984fa Mon Sep 17 00:00:00 2001 From: Tiara Lena Hock Date: Wed, 13 Aug 2025 10:21:36 +0200 Subject: [PATCH 5/7] Properly test oci/index.py * Create new oci test directory * Move existing oci tests * Create dedicated test file for oci/index.py --- tests/oci/__init__.py | 0 tests/oci/test_index.py | 87 +++++++++++++++++++++++++++++++++++++ tests/{ => oci}/test_oci.py | 4 +- 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 tests/oci/__init__.py create mode 100644 tests/oci/test_index.py rename tests/{ => oci}/test_oci.py (99%) diff --git a/tests/oci/__init__.py b/tests/oci/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/oci/test_index.py b/tests/oci/test_index.py new file mode 100644 index 00000000..ca3aa18d --- /dev/null +++ b/tests/oci/test_index.py @@ -0,0 +1,87 @@ +import io +import json +import pytest + +from gardenlinux.oci.index import Index + + +def test_index_init_and_json(): + """Ensure Index init works correctly""" + # Arrange + idx = Index() + + # Act + json_bytes = idx.json + decoded = json.loads(json_bytes.decode("utf-8")) + + # Assert + assert "schemaVersion" in idx + assert isinstance(json_bytes, bytes) + assert decoded == idx + + +def test_manifests_as_dict(): + """Verify manifests_as_dict returns correct keys for cname and digest cases.""" + # Arrange + idx = Index() + manifest_cname = {"digest": "sha256:abc", "annotations": {"cname": "foo"}} + manifest_no_cname = {"digest": "sha256:def"} + idx["manifests"] = [manifest_cname, manifest_no_cname] + + # Act + result = idx.manifests_as_dict + + # Assert + assert result["foo"] == manifest_cname + assert result["sha256:def"] == manifest_no_cname + + +def test_append_manifest_replace(): + """Ensure append_manifest replaces existing manifest with same cname.""" + # Arrange + idx = Index() + idx["manifests"] = [ + {"annotations": {"cname": "old"}, "digest": "sha256:old"}, + {"annotations": {"cname": "other"}, "digest": "sha256:other"}, + ] + new_manifest = {"annotations": {"cname": "old"}, "digest": "sha256:new"} + + # Act + idx.append_manifest(new_manifest) + + # Assert + cnames = [manifest["annotations"]["cname"] for manifest in idx["manifests"]] + assert "old" in cnames + assert any(manifest["digest"] == "sha256:new" for manifest in idx["manifests"]) + + +def test_append_manifest_cname_not_found(): + """Test appending new manifest if cname isn't found.""" + # Arrange + idx = Index() + idx["manifests"] = [{"annotations": {"cname": "foo"}, "digest": "sha256:foo"}] + new_manifest = {"annotations": {"cname": "bar"}, "digest": "sha256:bar"} + + # Act + idx.append_manifest(new_manifest) + + # Assert + cnames = [manifest["annotations"]["cname"] for manifest in idx["manifests"]] + assert "bar" in cnames + + +@pytest.mark.parametrize( + "bad_manifest", + [ + "not-a-dict", + {"annotations": {}}, + ], +) +def test_append_invalid_input_raises(bad_manifest): + """Test proper error handling for invalid append_manifest input.""" + # Arrange + idx = Index() + + # Act / Assert + with pytest.raises(RuntimeError): + idx.append_manifest(bad_manifest) diff --git a/tests/test_oci.py b/tests/oci/test_oci.py similarity index 99% rename from tests/test_oci.py rename to tests/oci/test_oci.py index 521b3c07..d0fcdce3 100644 --- a/tests/test_oci.py +++ b/tests/oci/test_oci.py @@ -2,8 +2,6 @@ from click.testing import CliRunner import sys import json -import os -import logging # Import reggie library correctly from oras.provider import Registry @@ -12,7 +10,7 @@ from gardenlinux.oci.__main__ import cli as gl_oci -from .constants import ( +from ..constants import ( CONTAINER_NAME_ZOT_EXAMPLE, GARDENLINUX_ROOT_DIR_EXAMPLE, REGISTRY, From 8ff75bace0b7d234c858393cdf5b458ebc6ddb78 Mon Sep 17 00:00:00 2001 From: Tiara Lena Hock Date: Wed, 13 Aug 2025 12:15:47 +0200 Subject: [PATCH 6/7] Bring s3_artifacts test coverage back up --- tests/s3/test_s3_artifacts.py | 107 ++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/s3/test_s3_artifacts.py b/tests/s3/test_s3_artifacts.py index a105a19d..56df965b 100644 --- a/tests/s3/test_s3_artifacts.py +++ b/tests/s3/test_s3_artifacts.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import yaml from pathlib import Path from tempfile import TemporaryDirectory import pytest @@ -69,6 +70,26 @@ def test_download_to_directory_invalid_path(s3_setup): artifacts.download_to_directory({env.cname}, "/invalid/path/does/not/exist") +def test_download_to_directory_non_pathlike_raises(s3_setup): + """Raise RuntimeError if artifacts_dir is not a dir""" + env = s3_setup + artifacts = S3Artifacts(env.bucket_name) + with pytest.raises(RuntimeError): + artifacts.download_to_directory(env.cname, "nopath") + + +def test_download_to_directory_no_metadata_raises(s3_setup): + """Should raise IndexError if bucket has no matching metadata object.""" + # Arrange + env = s3_setup + artifacts = S3Artifacts(env.bucket_name) + + # Act / Assert + with TemporaryDirectory() as tmpdir: + with pytest.raises(IndexError): + artifacts.download_to_directory(env.cname, tmpdir) + + def test_upload_from_directory_success(s3_setup): """ Test upload of multiple artifacts from disk to bucket @@ -125,3 +146,89 @@ def test_upload_from_directory_with_delete(s3_setup): # but the new upload file key should exist (artifact uploaded) assert f"objects/{env.cname}/{artifact.name}" in keys assert f"meta/singles/{env.cname}" in keys + + +def test_upload_from_directory_arch_none_raises(monkeypatch, s3_setup): + """Raise RuntimeError when CName has no arch""" + # Arrange + env = s3_setup + release_path = env.tmp_path / f"{env.cname}.release" + release_path.write_text(RELEASE_DATA) + + # Monkeypatch CName to simulate missing architecture + import gardenlinux.s3.s3_artifacts as s3art + + class DummyCName: + arch = None + + def __init__(self, cname): + pass + + monkeypatch.setattr(s3art, "CName", DummyCName) + + # Act / Assert + artifacts = S3Artifacts(env.bucket_name) + with pytest.raises(RuntimeError, match="Architecture could not be determined"): + artifacts.upload_from_directory(env.cname, env.tmp_path) + + +def test_upload_from_directory_invalid_dir_raises(s3_setup): + """Raise RuntimeError if artifacts_dir is invalid""" + env = s3_setup + artifacts = S3Artifacts(env.bucket_name) + with pytest.raises(RuntimeError, match="invalid"): + artifacts.upload_from_directory(env.cname, "/invalid/path") + + +def test_upload_from_directory_version_mismatch_raises(s3_setup): + """ + RuntimeError if version in release file does not match cname. + """ + # Arrange + env = s3_setup + release_path = env.tmp_path / f"{env.cname}.release" + bad_data = RELEASE_DATA.replace("1234.1", "9999.9") + release_path.write_text(bad_data) + artifacts = S3Artifacts(env.bucket_name) + + # Act / Assert + with pytest.raises(RuntimeError, match="Version"): + artifacts.upload_from_directory(env.cname, env.tmp_path) + + +def test_upload_from_directory_commit_mismatch_raises(s3_setup): + """Raise RuntimeError when commit ID is not matching with cname.""" + # Arrange + env = s3_setup + release_path = env.tmp_path / f"{env.cname}.release" + bad_data = RELEASE_DATA.replace("abc123", "wrong") + release_path.write_text(bad_data) + artifacts = S3Artifacts(env.bucket_name) + with pytest.raises(RuntimeError, match="Commit ID"): + artifacts.upload_from_directory(env.cname, env.tmp_path) + + +def test_upload_directory_with_requirements_override(s3_setup): + """Ensure .requirements file values overide feature flag defaults.""" + # Arrange + env = s3_setup + (env.tmp_path / f"{env.cname}.release").write_text(RELEASE_DATA) + (env.tmp_path / f"{env.cname}.requirements").write_text( + "uefi = false\nsecureboot = true\n" + ) + artifact_file = env.tmp_path / f"{env.cname}-artifact" + artifact_file.write_bytes(b"abc") + + # Act + artifacts = S3Artifacts(env.bucket_name) + artifacts.upload_from_directory(env.cname, env.tmp_path) + + # Assert + bucket = env.s3.Bucket(env.bucket_name) + meta_obj = next( + o for o in bucket.objects.all() if o.key == f"meta/singles/{env.cname}" + ) + body = meta_obj.get()["Body"].read().decode() + metadata = yaml.safe_load(body) + assert metadata["require_uefi"] is False + assert metadata["secureboot"] is True From ae4052211067b4463e101d6f0ea078823adfc108 Mon Sep 17 00:00:00 2001 From: Tiara Lena Hock Date: Wed, 13 Aug 2025 12:27:38 +0200 Subject: [PATCH 7/7] Add tests for oci/layer.py --- tests/oci/test_layer.py | 141 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 tests/oci/test_layer.py diff --git a/tests/oci/test_layer.py b/tests/oci/test_layer.py new file mode 100644 index 00000000..14398936 --- /dev/null +++ b/tests/oci/test_layer.py @@ -0,0 +1,141 @@ +import builtins +import pytest +from pathlib import Path + +import gardenlinux.oci.layer as gl_layer + + +class DummyLayer: + """Minimal stub for oras.oci.Layer""" + + def __init__(self, blob_path, media_type=None, is_dir=False): + self._init_args = (blob_path, media_type, is_dir) + + def to_dict(self): + return {"dummy": True} + + +@pytest.fixture(autouse=True) +def patch__Layer(monkeypatch): + """Replace oras.oci.Layer with DummyLayer in Layer's module.""" + monkeypatch.setattr(gl_layer, "_Layer", DummyLayer) + yield + + +def test_dict_property_returns_with_annotations(tmp_path): + """dict property should merge _Layer.to_dict() with annotations.""" + # Arrange + blob = tmp_path / "blob.txt" + blob.write_text("data") + + # Act + l = gl_layer.Layer(blob) + result = l.dict + + # Assert + assert result["dummy"] is True + assert "annotations" in result + assert result["annotations"]["org.opencontainers.image.title"] == "blob.txt" + + +def test_getitem_and_delitem_annotations(tmp_path): + """getitem should return annotations, delitem should clear them.""" + # Arrange + blob = tmp_path / "blob.txt" + blob.write_text("data") + l = gl_layer.Layer(blob) + + # Act / Assert (__getitem__) + ann = l["annotations"] + assert isinstance(ann, dict) + assert "org.opencontainers.image.title" in ann + + # Act / Assert (__delitem__) + l.__delitem__("annotations") + assert l._annotations == {} + + +def test_getitem_invalid_key_raises(tmp_path): + """getitem with unsupported key should raise KeyError.""" + # Arrange + blob = tmp_path / "blob.txt" + blob.write_text("data") + l = gl_layer.Layer(blob) + + # Act / Assert + with pytest.raises(KeyError): + _ = l["invalid"] + + +def test_setitem_annotations(tmp_path): + """setitem with supported keys should set annotations""" + # Arrange + blob = tmp_path / "blob.txt" + blob.write_text("data") + l = gl_layer.Layer(blob) + + # Act + new_ann = {"x": "y"} + l.__setitem__("annotations", new_ann) + + # Assert + assert l._annotations == new_ann + + +def test_setitem_annotations_invalid_raises(tmp_path): + # Arrange + blob = tmp_path / "blob.txt" + blob.write_text("data") + l = gl_layer.Layer(blob) + + # Act / Assert + with pytest.raises(KeyError): + _ = l["invalid"] + + +def test_len_iter(tmp_path): + # Arrange + blob = tmp_path / "blob.txt" + blob.write_text("data") + l = gl_layer.Layer(blob) + + # Act + keys = list(iter(l)) + + # Assert + assert keys == ["annotations"] + assert len(keys) == 1 + + +def test_gen_metadata_from_file(tmp_path): + # Arrange + blob = tmp_path / "blob.tar" + blob.write_text("data") + l = gl_layer.Layer(blob) + + # Act + arch = "amd64" + metadata = gl_layer.Layer.generate_metadata_from_file_name(blob, arch) + + # Assert + assert metadata["file_name"] == "blob.tar" + assert "media_type" in metadata + assert metadata["annotations"]["io.gardenlinux.image.layer.architecture"] == arch + + +def test_lookup_media_type_for_file_name(tmp_path): + # Arrange + blob = tmp_path / "blob.tar" + blob.write_text("data") + + # Act + media_type = gl_layer.Layer.lookup_media_type_for_file_name(blob) + from gardenlinux.constants import GL_MEDIA_TYPE_LOOKUP + + assert media_type == GL_MEDIA_TYPE_LOOKUP["tar"] + + +def test_lookup_media_type_for_file_name_invalid_raises(tmp_path): + # Arrange / Act / Assert + with pytest.raises(ValueError): + gl_layer.Layer.lookup_media_type_for_file_name(tmp_path / "unknown.xyz")