"""Output collection: required and nonempty, hashed, bound to the source revision, and unable to build.""" import copy import hashlib import json import os import subprocess import tempfile import unittest from pathlib import Path from typing import cast, override from unittest import mock from platform_ci import packaging from platform_ci.packaging import MANIFEST_FILE_NAME, PackagingError, RunIdentity, collect_outputs, manifest_digest from platform_ci.plan import Target from tests.support import PLATFORM_REVISION, SOURCE_REVISION, SOURCE_TREE, event, example_configuration, plan_for REPORT_CONTENT = b"" JAR_CONTENT = b"packaged bytes" def _files(manifest: dict[str, object]) -> list[dict[str, object]]: """Return the manifest's file entries with a usable type.""" return cast("list[dict[str, object]]", manifest["files"]) class OutputCollectionTest(unittest.TestCase): """Collection reads what the verification build left behind and records evidence about it.""" @override def setUp(self) -> None: """Create a checkout holding Maven-shaped build output and the Maven example's target.""" directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.source = Path(directory.name) / "checkout" self.output = Path(directory.name) / "bundle" (self.source / "target" / "surefire-reports").mkdir(parents=True) (self.source / "target" / "surefire-reports" / "TEST-a.xml").write_bytes(REPORT_CONTENT) (self.source / "target" / "app.jar").write_bytes(JAR_CONTENT) self.target = plan_for(example_configuration("maven.ci.yaml"), event())["targets"][0] self.identity = RunIdentity(SOURCE_REVISION, PLATFORM_REVISION, "42", "1", SOURCE_TREE) def test_collects_declared_outputs_with_hashes_and_run_metadata(self) -> None: """Every declared file is copied and recorded with its size and SHA-256, plus source and run identity.""" manifest = collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True) hashes = {entry["path"]: entry["sha256"] for entry in _files(manifest)} self.assertEqual(hashes["artifact/packages/target/app.jar"], hashlib.sha256(JAR_CONTENT).hexdigest()) self.assertEqual(hashes["report/junit/target/surefire-reports/TEST-a.xml"], hashlib.sha256(REPORT_CONTENT).hexdigest()) self.assertEqual((self.output / "artifact" / "packages" / "target" / "app.jar").read_bytes(), JAR_CONTENT) self.assertEqual((manifest["source_revision"], manifest["platform_revision"], manifest["run_id"]), (SOURCE_REVISION, PLATFORM_REVISION, "42")) self.assertEqual(json.loads((self.output / MANIFEST_FILE_NAME).read_text(encoding="utf-8")), manifest) def test_digest_is_bound_to_source_revision_and_file_hashes(self) -> None: """Changing the source revision or any file hash changes the manifest digest.""" manifest = collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True) self.assertEqual(manifest["digest"], manifest_digest(manifest)) other_source = copy.deepcopy(manifest) other_source["source_revision"] = "c" * 40 self.assertNotEqual(manifest_digest(other_source), manifest["digest"]) tampered = copy.deepcopy(manifest) _files(tampered)[0]["sha256"] = "0" * 64 self.assertNotEqual(manifest_digest(tampered), manifest["digest"]) def test_missing_and_empty_outputs_fail_after_a_successful_build(self) -> None: """A declared artifact or report that is absent or empty is an error, never a silent pass.""" (self.source / "target" / "app.jar").write_bytes(b"") with self.assertRaisesRegex(PackagingError, "is empty"): collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True) (self.source / "target" / "app.jar").unlink() with self.assertRaisesRegex(PackagingError, "artifact 'packages' matched no files"): collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True) self.assertFalse(self.output.exists()) def test_failed_build_keeps_reports_only(self) -> None: """After a failed build the reports are kept for diagnosis and no artifact is collected.""" manifest = collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=False) self.assertFalse(manifest["build_succeeded"]) self.assertEqual([entry["kind"] for entry in _files(manifest)], ["report"]) self.assertFalse((self.output / "artifact").exists()) def _optional_junit_target(self) -> Target: """Return the Maven example's target with its JUnit report optional and a second required file report.""" target = copy.deepcopy(self.target) target["reports"] = {"junit": {"format": "junit", "paths": ["**/target/surefire-reports/*.xml"], "required": False}} return target def test_absent_optional_report_passes_and_present_one_is_retained(self) -> None: """An optional report that matches nothing is fine after a successful build; one that matches is kept.""" target = self._optional_junit_target() kept = collect_outputs(target, self.source, self.output, self.identity, build_succeeded=True) self.assertIn("report/junit/target/surefire-reports/TEST-a.xml", {entry["path"] for entry in _files(kept)}) (self.source / "target" / "surefire-reports" / "TEST-a.xml").unlink() absent = collect_outputs(target, self.source, self.source.parent / "bundle-absent", self.identity, build_succeeded=True) self.assertEqual([entry["kind"] for entry in _files(absent)], ["artifact"]) def test_missing_required_report_fails_after_a_successful_build(self) -> None: """A required report (the default) that matches nothing fails, even with every artifact present.""" (self.source / "target" / "surefire-reports" / "TEST-a.xml").unlink() with self.assertRaisesRegex(PackagingError, "report 'junit' matched no files"): collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True) self.assertFalse(self.output.exists()) def test_every_path_of_a_report_is_collected(self) -> None: """A typed report with several globs retains the files of all of them.""" (self.source / "target" / "failsafe-reports").mkdir() (self.source / "target" / "failsafe-reports" / "TEST-b.xml").write_bytes(REPORT_CONTENT) target = copy.deepcopy(self.target) target["reports"]["junit"]["paths"] = ["target/surefire-reports/*.xml", "target/failsafe-reports/*.xml"] manifest = collect_outputs(target, self.source, self.output, self.identity, build_succeeded=True) reports = {str(entry["path"]) for entry in _files(manifest) if entry["kind"] == "report"} self.assertEqual(reports, {"report/junit/target/surefire-reports/TEST-a.xml", "report/junit/target/failsafe-reports/TEST-b.xml"}) def test_failed_build_with_optional_report_and_missing_artifact_keeps_available_reports(self) -> None: """A failed build never requires anything: available reports are kept, nothing else, and no error is raised.""" (self.source / "target" / "app.jar").unlink() manifest = collect_outputs(self._optional_junit_target(), self.source, self.output, self.identity, build_succeeded=False) self.assertEqual([entry["kind"] for entry in _files(manifest)], ["report"]) def test_artifact_requirements_still_fail_when_reports_are_optional(self) -> None: """Making reports optional does not relax artifacts: a missing artifact still fails a successful build.""" (self.source / "target" / "app.jar").unlink() with self.assertRaisesRegex(PackagingError, "artifact 'packages' matched no files"): collect_outputs(self._optional_junit_target(), self.source, self.output, self.identity, build_succeeded=True) def _broad_target(self) -> Target: """Return the Maven target with an artifact glob that matches every file under the checkout.""" target = copy.deepcopy(self.target) target["artifacts"] = {"everything": ["**/*"]} return target def test_broad_glob_excludes_git_metadata_platform_checkout_and_output(self) -> None: """`**/*` collects build files only, and never copies the bundle into itself.""" (self.source / ".git").mkdir() (self.source / ".git" / "config").write_bytes(b"[core]") (self.source / ".platform-ci" / "platform_ci").mkdir(parents=True) (self.source / ".platform-ci" / "platform_ci" / "packaging.py").write_bytes(b"implementation") output = self.source / "ci-output" manifest = collect_outputs(self._broad_target(), self.source, output, self.identity, build_succeeded=True) collected = {str(entry["path"]) for entry in _files(manifest) if entry["kind"] == "artifact"} self.assertEqual(collected, {"artifact/everything/target/app.jar", "artifact/everything/target/surefire-reports/TEST-a.xml"}) self.assertFalse(any(".git" in path or ".platform-ci" in path or "ci-output" in path for path in collected)) def test_symlink_escaping_the_component_directory_is_rejected(self) -> None: """A link to a file or directory outside the checkout fails collection and leaves no bundle.""" outside = Path(self.source.parent) / "outside" outside.mkdir() (outside / "secret.txt").write_bytes(b"not part of the build") for name, destination in {"stolen-file.jar": outside / "secret.txt", "stolen-directory.jar": outside}.items(): with self.subTest(name): link = self.source / "target" / name try: link.symlink_to(destination, target_is_directory=destination.is_dir()) except (OSError, NotImplementedError): self.skipTest("this platform does not allow the test to create symbolic links") with self.assertRaisesRegex(PackagingError, "resolves outside the component directory"): collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True) self.assertFalse(self.output.exists()) link.unlink() def test_symlink_into_git_metadata_is_rejected(self) -> None: """A link inside the checkout that points at Git metadata is an error, not a collected file.""" (self.source / ".git").mkdir() (self.source / ".git" / "config").write_bytes(b"[core]") link = self.source / "target" / "config.jar" try: link.symlink_to(self.source / ".git" / "config") except (OSError, NotImplementedError): self.skipTest("this platform does not allow the test to create symbolic links") with self.assertRaisesRegex(PackagingError, "resolves into Git metadata"): collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True) def test_packaging_cannot_invoke_a_build(self) -> None: """Collection works with every way of starting a process disabled, and the module has no such capability.""" def refuse(*_arguments: object, **_keywords: object) -> None: raise AssertionError("packaging must not start a process") with mock.patch.object(subprocess, "run", refuse), mock.patch.object(subprocess, "Popen", refuse), mock.patch.object(os, "system", refuse): collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True) source_text = Path(packaging.__file__).read_text(encoding="utf-8") for forbidden in ("subprocess", "os.system", "platform_ci.execution"): self.assertNotIn(forbidden, source_text)