"""JUnit report judgment: legacy Maven allow-empty behavior, strict XML handling, workspace containment.""" import tempfile import unittest from pathlib import Path from typing import override from platform_ci.test_reports import JUnitSummary, TestReportError, check_junit_reports PASSING = '' class JUnitReportTest(unittest.TestCase): """Reports are parsed as data; only failures, errors and malformed reports fail.""" @override def setUp(self) -> None: """Create an empty workspace.""" directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.workspace = Path(directory.name).resolve() def _report(self, content: str | bytes, name: str = "target/surefire-reports/TEST-a.xml") -> Path: path = self.workspace / name path.parent.mkdir(parents=True, exist_ok=True) if isinstance(content, str): content = content.encode("utf-8") path.write_bytes(content) return Path(name) def _check(self, *contents: str | bytes) -> JUnitSummary: return check_junit_reports(self.workspace, [self._report(content, f"r/TEST-{index}.xml") for index, content in enumerate(contents)]) def test_passing_report_is_summarized(self) -> None: """Counts are totaled and skipped tests are not executed ones.""" summary = self._check(PASSING, '') self.assertTrue(summary.passed) self.assertEqual((summary.reports, summary.tests, summary.skipped, summary.executed, summary.failures, summary.errors), (2, 5, 1, 4, 0, 0)) def test_zero_reports_zero_tests_and_all_skipped_are_allowed(self) -> None: """The old Maven --allow-empty behavior: no new zero-report or all-skipped gate.""" self.assertTrue(check_junit_reports(self.workspace, []).passed) self.assertTrue(self._check('').passed) all_skipped = self._check('') self.assertTrue(all_skipped.passed) self.assertEqual(all_skipped.executed, 0) def test_failed_or_errored_counts_fail(self) -> None: """A nonzero failures or errors attribute fails, and every report is still totaled.""" for attributes, expected in (('failures="1"', (1, 0)), ('errors="2"', (0, 2))): with self.subTest(attributes): summary = self._check(f'') self.assertFalse(summary.passed) self.assertEqual((summary.failures, summary.errors), expected) def test_failing_or_erroring_testcases_fail_even_when_counts_claim_success(self) -> None: """Child elements are authoritative over a summary that says zero.""" for child, expected in (("failure", (1, 0)), ("error", (0, 1))): with self.subTest(child): summary = self._check(f'<{child} message="m"/>') self.assertFalse(summary.passed) self.assertEqual((summary.failures, summary.errors), expected) def test_testsuites_root_sums_its_suites(self) -> None: """An aggregate root counts each direct suite once.""" summary = self._check('') self.assertEqual((summary.tests, summary.skipped, summary.failures), (5, 1, 1)) self.assertFalse(summary.passed) def test_one_bad_report_fails_but_the_others_are_still_counted(self) -> None: """Problems name the workspace-relative report and never include report content.""" summary = self._check(PASSING, '') self.assertEqual(summary.tests, 4) self.assertEqual(len(summary.problems), 1) self.assertTrue(summary.problems[0].startswith("r/TEST-1.xml:")) def test_malformed_reports_fail(self) -> None: """Broken XML, wrong roots, empty files, bad counts and skipped above tests are all problems.""" cases = { "truncated": "", "empty": "", "not xml": "tests passed", "wrong root": '', "negative count": '', "word count": '', "skipped above tests": '', "invalid bytes": b"\xff\xfe\x00 None: """DTD and entity declarations fail, including an entity bomb, and nothing is expanded or fetched.""" bomb = ']>' external = '' entity_only = ']>' internal = ']>' cases: dict[str, str | bytes] = { "bomb": bomb, "external dtd": external, "external entity": entity_only, "internal entity": internal, "utf-16 bomb": bomb.encode("utf-16"), "utf-16 external entity": entity_only.encode("utf-16"), "utf-16 internal entity": internal.encode("utf-16"), "utf-8 with declaration": b'' + external.encode("utf-8"), } for description, content in cases.items(): with self.subTest(description): summary = self._check(content) self.assertFalse(summary.passed) self.assertIn("not allowed", summary.problems[0]) def test_utf16_report_without_declarations_is_parsed_once(self) -> None: """A BOM-marked UTF-16 report parses normally, and nested text and cases are counted once.""" summary = self._check(PASSING.encode("utf-16")) self.assertTrue(summary.passed) self.assertEqual((summary.tests, summary.skipped), (3, 1)) def test_report_outside_the_workspace_is_rejected_including_through_a_symlink(self) -> None: """An absolute path, parent traversal and a link to an outside file all raise.""" with tempfile.TemporaryDirectory() as outside_name: outside = Path(outside_name) / "TEST-out.xml" outside.write_text(PASSING, encoding="utf-8") with self.assertRaises(TestReportError): check_junit_reports(self.workspace, [outside]) link = self.workspace / "link.xml" try: link.symlink_to(outside) except OSError: self.skipTest("symbolic links are not available") with self.assertRaises(TestReportError): check_junit_reports(self.workspace, [Path("link.xml")]) with self.assertRaises(TestReportError): check_junit_reports(self.workspace, [Path("../elsewhere.xml")]) def test_git_metadata_platform_checkout_missing_files_and_directories_are_rejected(self) -> None: """Only real report files in the checkout are read; a missing declared report is packaging's concern, not a pass here.""" git_report = self._report(PASSING, ".git/TEST-a.xml") platform_report = self._report(PASSING, ".platform-ci/TEST-a.xml") (self.workspace / "dir").mkdir() for path in (git_report, platform_report, Path("missing.xml"), Path("dir")): with self.subTest(str(path)), self.assertRaises(TestReportError): check_junit_reports(self.workspace, [path]) def test_parser_runs_no_tools(self) -> None: """The module imports no process-starting facility, so tests can never be rerun from here.""" source = (Path(__file__).resolve().parent.parent / "platform_ci" / "test_reports.py").read_text(encoding="utf-8") for forbidden in ("subprocess", "os.system", "socket", "urllib"): self.assertNotIn(f"import {forbidden}", source)