149 lines
8.5 KiB
Python
149 lines
8.5 KiB
Python
#!/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": ["<test> & 新版本", "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"), "<test> & 新版本\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": ["<English> & 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()
|