import io import pathlib import sys import unittest import zipfile from typing import final ROOT = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from junit import ArtifactError, classify_reports, read_junit_zip from model import Report FIXTURES = pathlib.Path(__file__).parent / "fixtures" def zipped(name: str) -> bytes: data = io.BytesIO() with zipfile.ZipFile(data, "w") as archive: archive.writestr("junit.xml", (FIXTURES / name).read_bytes()) return data.getvalue() @final class JunitTest(unittest.TestCase): def parse(self, fixture: str, attempt: int) -> Report: return read_junit_zip(zipped(fixture), attempt) def test_nextest_retry_succeeds(self): occurrences = classify_reports("owner/repo", ".github/workflows/unit-test.yml", 9, 1, [self.parse("nextest_flaky.xml", 1)]) self.assertEqual(1, len(occurrences)) self.assertEqual(frozenset(("nextest retry",)), occurrences[0].modes) def test_nextest_retries_never_recover(self): self.assertEqual([], classify_reports("owner/repo", ".github/workflows/unit-test.yml", 9, 1, [self.parse("nextest_failed.xml", 1)])) def test_failure_with_flaky_failure_still_classified_as_pass(self): data = io.BytesIO() with zipfile.ZipFile(data, "w") as archive: archive.writestr("junit.xml", b'') report = read_junit_zip(data.getvalue(), 1) self.assertEqual("pass", report.testcases[0].result) self.assertEqual(1, report.testcases[0].flaky_retries) occurrences = classify_reports("owner/repo", ".github/workflows/unit-test.yml", 9, 1, [report]) self.assertEqual(1, len(occurrences)) self.assertEqual(frozenset(("nextest retry",)), occurrences[0].modes) def test_nextest_retry_in_earlier_workflow_attempt_is_preserved(self): reports = [self.parse("nextest_flaky.xml", 1), self.parse("pass.xml", 2)] occurrences = classify_reports("owner/repo", ".github/workflows/unit-test.yml", 9, 2, reports) self.assertEqual(1, len(occurrences)) self.assertEqual(1, occurrences[0].final_attempt) def test_workflow_rerun_succeeds(self): reports = [self.parse("failure.xml", 1), self.parse("pass.xml", 2)] occurrences = classify_reports("owner/repo", ".github/workflows/unit-test.yml", 9, 2, reports) self.assertEqual(frozenset(("workflow rerun",)), occurrences[0].modes) def test_final_workflow_attempt_fails(self): reports = [self.parse("pass.xml", 1), self.parse("failure.xml", 2)] self.assertEqual([], classify_reports("owner/repo", ".github/workflows/unit-test.yml", 9, 2, reports)) def test_missing_earlier_attempt_does_not_recover(self): self.assertEqual([], classify_reports("owner/repo", ".github/workflows/unit-test.yml", 9, 2, [self.parse("pass.xml", 2)])) def test_actual_latest_artifact_missing(self): reports = [self.parse("failure.xml", 1), self.parse("pass.xml", 2)] self.assertEqual([], classify_reports("owner/repo", ".github/workflows/unit-test.yml", 9, 3, reports)) def test_intermediate_artifact_missing(self): reports = [self.parse("failure.xml", 1), self.parse("pass.xml", 3)] self.assertEqual(1, len(classify_reports("owner/repo", ".github/workflows/unit-test.yml", 9, 3, reports))) def test_duplicate_testcase_is_ambiguous(self): report = self.parse("duplicate.xml", 1) self.assertIn(("pkg::tests", "duplicate"), report.ambiguous) def test_zip_rejects_traversal_and_multiple_reports(self): for names in (("../junit.xml",), ("a/junit.xml", "b/junit.xml")): data = io.BytesIO() with zipfile.ZipFile(data, "w") as archive: for name in names: archive.writestr(name, b"") with self.subTest(names=names), self.assertRaises(ArtifactError): _ = read_junit_zip(data.getvalue(), 1) def test_zip_accepts_safe_directory_entries(self): data = io.BytesIO() with zipfile.ZipFile(data, "w") as archive: archive.writestr("reports/", b"") archive.writestr("reports/junit.xml", b'') self.assertEqual(1, len(read_junit_zip(data.getvalue(), 1).testcases)) def test_zip_rejects_symlink_and_oversized_report(self): symlink = zipfile.ZipInfo("junit.xml") symlink.create_system = 3 symlink.external_attr = 0o120777 << 16 for member, content in ((symlink, b"target"), ("junit.xml", b"x" * (10 * 1024 * 1024 + 1))): data = io.BytesIO() with zipfile.ZipFile(data, "w") as archive: archive.writestr(member, content) with self.subTest(member=member), self.assertRaises(ArtifactError): _ = read_junit_zip(data.getvalue(), 1) def test_xml_rejects_doctype(self): data = io.BytesIO() with zipfile.ZipFile(data, "w") as archive: archive.writestr("junit.xml", b']>') with self.assertRaises(ArtifactError): _ = read_junit_zip(data.getvalue(), 1) def test_xml_rejects_utf16_doctype_and_entity(self): xml = ']>'.encode("utf-16") data = io.BytesIO() with zipfile.ZipFile(data, "w") as archive: archive.writestr("junit.xml", xml) with self.assertRaises(ArtifactError): _ = read_junit_zip(data.getvalue(), 1) def test_zip_rejects_windows_drive_unc_and_backslash_traversal(self): for unsafe in ("C:\\temp\\payload", "\\\\server\\share\\payload", "reports\\..\\payload"): data = io.BytesIO() with zipfile.ZipFile(data, "w") as archive: archive.writestr(unsafe, b"payload") archive.writestr("junit.xml", b'') with self.subTest(unsafe=unsafe), self.assertRaises(ArtifactError): _ = read_junit_zip(data.getvalue(), 1) def test_xml_rejects_zero_testcases(self): data = io.BytesIO() with zipfile.ZipFile(data, "w") as archive: archive.writestr("junit.xml", b"") with self.assertRaises(ArtifactError): _ = read_junit_zip(data.getvalue(), 1) if __name__ == "__main__": _ = unittest.main()