#!/usr/bin/env python3 # Copyright (C) 2026 ysicing # SPDX-License-Identifier: AGPL-3.0-or-later import base64 import hashlib import json from pathlib import Path import tempfile import unittest from unittest.mock import patch import xml.etree.ElementTree as ET from sparkle_appcast import NOTE_LANGUAGES, ROOT, SPARKLE, XML_LANG, XSTATS, load_localized_notes, load_manifest, make_feed, verify_feed class SparkleAppcastTests(unittest.TestCase): def test_xml_preserves_release_and_escapes_notes(self): feed = {"version": "1.0.0", "build": "200", "minimumSystem": "14.0", "size": 3, "url": "https://example.test/XStats-1.0.0-AppleSilicon.zip", "notes": [" & 新版本", "line two"], "date": "2026-09-30", "dmg": "https://example.test/update.dmg", "sha256": "a" * 64, "changelog": "https://example.test/changelog"} signature = base64.b64encode(b"s" * 64).decode() item = ET.fromstring(make_feed(feed, signature)).find("channel/item") self.assertEqual(item.findtext(f"{{{SPARKLE}}}version"), "200") self.assertEqual(item.findtext(f"{{{SPARKLE}}}shortVersionString"), "1.0.0") self.assertEqual(item.findtext(f"{{{SPARKLE}}}hardwareRequirements"), "arm64") self.assertEqual(item.findtext("description"), " & 新版本\nline two") self.assertEqual(item.find("description").get(f"{{{SPARKLE}}}format"), "plain-text") for field in ("date", "dmg", "sha256"): self.assertEqual(item.findtext(f"{{{XSTATS}}}{field}"), feed[field]) self.assertEqual(item.findtext(f"{{{SPARKLE}}}fullReleaseNotesLink"), feed["changelog"]) self.assertEqual(item.find("enclosure").get(f"{{{SPARKLE}}}edSignature"), signature) with self.assertRaises(ValueError): make_feed(feed, base64.b64encode(b"short").decode()) def test_verify_rejects_wrong_metadata_even_before_signature_tools(self): feed = {"version": "1.0.0", "build": "200", "minimumSystem": "14.0", "size": 3, "url": "https://example.test/XStats-1.0.0-AppleSilicon.zip", "notes": ["test"]} signature = base64.b64encode(b"s" * 64).decode() with tempfile.TemporaryDirectory() as directory, patch("sparkle_appcast.run_tool") as tool: xml = Path(directory) / "update.xml" for field, value in (("build", "201"), ("url", "https://example.test/other.zip"), ("size", 4), ("notes", ["other"]), ("minimumSystem", "15.0")): with self.subTest(field=field): xml.write_bytes(make_feed({**feed, field: value}, signature)) with self.assertRaises(ValueError): verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test") root = ET.fromstring(make_feed(feed, signature)) root.find("channel/item").find(f"{{{SPARKLE}}}hardwareRequirements").text = "x86_64" xml.write_bytes(ET.tostring(root)) with self.assertRaises(ValueError): verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test") root = ET.fromstring(make_feed(feed, signature)) root.find("channel").append(ET.fromstring(ET.tostring(root.find("channel/item")))) xml.write_bytes(ET.tostring(root)) with self.assertRaises(ValueError): verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test") tool.assert_not_called() def test_english_translation_matches_the_current_changelog(self): from appcast import release_notes source = json.loads((ROOT / "ReleaseNotes.json").read_text()) _, notes = release_notes((ROOT / "CHANGELOG.md").read_text(), source["version"]) localized = load_localized_notes({"version": source["version"], "notes": notes}) self.assertEqual(set(localized), {"en", "zh-Hans"}) self.assertEqual(localized["zh-Hans"], notes) def test_stale_incomplete_or_invalid_translations_are_rejected(self): feed = {"version": "1.0.0", "notes": ["source"]} source = {"version": "1.0.0", "sourceNotes": ["source"], "translations": {language: [language] for language in NOTE_LANGUAGES if language != "zh-Hans"}} with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "ReleaseNotes.json" path.write_text(json.dumps(source)) self.assertEqual(load_localized_notes(feed, path)["en"], ["en"]) mutations = [ {**source, "version": "0.9.0"}, {**source, "sourceNotes": ["stale"]}, {**source, "translations": {}}, {**source, "translations": {**source["translations"], "ja": ["extra"]}}, *[{**source, "translations": {**source["translations"], "en": value}} for value in ([], [""], ["one", "two"], ["split\nline"], [1])], ] for value in mutations: with self.subTest(value=value): path.write_text(json.dumps(value)) with self.assertRaises(ValueError): load_localized_notes(feed, path) def test_signed_xml_contains_native_and_application_language_notes(self): feed = {"version": "1.0.0", "build": "200", "minimumSystem": "14.0", "size": 3, "url": "https://example.test/update.zip", "notes": ["中文"]} localized = {"zh-Hans": feed["notes"], "en": [" & details"]} signature = base64.b64encode(b"s" * 64).decode() with tempfile.TemporaryDirectory() as directory, patch("sparkle_appcast.run_tool") as tool: xml = Path(directory) / "update.xml" data = make_feed(feed, signature, localized) xml.write_bytes(data) verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test", localized) self.assertEqual(tool.call_count, 2) item = ET.fromstring(data).find("channel/item") descriptions = item.findall("description") self.assertEqual(descriptions[0].get(XML_LANG), "en") self.assertEqual({node.get(XML_LANG): node.text for node in descriptions}, {language: "\n".join(notes) for language, notes in localized.items()}) for field in ("description", f"{{{XSTATS}}}notes-en"): root = ET.fromstring(data) root.find("channel/item").find(field).text = "tampered" xml.write_bytes(ET.tostring(root)) tool.reset_mock() with self.assertRaises(ValueError): verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test", localized) tool.assert_not_called() root = ET.fromstring(data) item = root.find("channel/item") item.append(ET.fromstring(ET.tostring(item.find("description")))) xml.write_bytes(ET.tostring(root)) with self.assertRaises(ValueError): verify_feed(feed, xml, Path(directory) / "update.zip", Path(directory), "test", localized) def test_feed_size_limit_includes_translations(self): feed = {"version": "1.0.0", "build": "200", "minimumSystem": "14.0", "size": 3, "url": "https://example.test/update.zip", "notes": ["source"]} with self.assertRaises(ValueError): make_feed(feed, base64.b64encode(b"s" * 64).decode(), {"en": ["x" * (256 * 1024)]}) def test_stale_manifest_or_wrong_archive_is_rejected(self): with tempfile.TemporaryDirectory() as directory: archive = Path(directory) / "XStats-1.0.0-AppleSilicon.zip" archive.write_bytes(b"zip") manifest = Path(directory) / "appcast.json" feed = {"version": "1.0.0", "build": "200", "size": 3, "notes": ["test"], "url": f"https://example.test/{archive.name}", "sha256": hashlib.sha256(b"zip").hexdigest()} manifest.write_text(json.dumps(feed)) self.assertEqual(load_manifest(manifest, archive), feed) archive.write_bytes(b"modified archive") with self.assertRaises(ValueError): load_manifest(manifest, archive) archive.write_bytes(b"zip") feed["url"] = "https://example.test/Another.zip" manifest.write_text(json.dumps(feed)) with self.assertRaises(ValueError): load_manifest(manifest, archive) if __name__ == "__main__": unittest.main()