Files
XStats/scripts/publish_api_test.py
T
ysicingandClaude Opus 5.5 1623effd61 fix(release): 发布后逐区域核验 Sparkle 更新源并覆盖跳转策略
- 服务端保留探测标识(64 个 0):照常验签返回清单但不计入统计
- publish_api.py 新增 --verify-sparkle,JSON 发布后逐区域取回 XML 与本地签名文件逐字节比对,publish_release.sh 自动执行
- Cache-Control 探测按指令 token 判断,兼容代理合并的缓存头
- 抽出 feedRedirectPolicy 供测试复用,覆盖 HTTPS 跳转、HTTP 降级、跳转过多、404、传输错误
- 新增测试校验服务端公钥与 Info.plist、project.yml 的 SUPublicEDKey 一致

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:20:08 +08:00

141 lines
7.3 KiB
Python

#!/usr/bin/env python3
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
import contextlib
import io
import json
import tempfile
import threading
import unittest
import urllib.error
from unittest.mock import patch
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import appcast
import publish_api
class PublishAPITest(unittest.TestCase):
def test_defaults_to_both_regional_endpoints(self) -> None:
self.assertEqual(publish_api.configured_endpoints({}), [
"https://xstats-apps.12306.work/api/v1/releases/current",
"https://x-stats.china.12306.work/api/v1/releases/current",
])
def test_sparkle_gate_checks_both_regions_without_installation_data(self) -> None:
errors = [urllib.error.HTTPError(url, 400, "invalid request", {"Cache-Control": "no-store"}, io.BytesIO(b""))
for url in publish_api.DEFAULT_ENDPOINTS]
with patch("publish_api.urllib.request.urlopen", side_effect=errors) as send:
publish_api.check_sparkle_endpoints(list(publish_api.DEFAULT_ENDPOINTS))
urls = [call.args[0].full_url for call in send.call_args_list]
self.assertEqual(urls, [
"https://xstats-apps.12306.work/api/v1/update/appcast.xml",
"https://x-stats.china.12306.work/api/v1/update/appcast.xml",
])
for call in send.call_args_list:
self.assertNotIn("installation_id", call.args[0].full_url)
self.assertIsNone(call.args[0].data)
self.assertFalse(call.args[0].has_header("Authorization"))
def test_sparkle_gate_accepts_no_store_merged_by_proxy(self) -> None:
errors = [urllib.error.HTTPError(url, 400, "invalid request", {"Cache-Control": "private, No-Store"}, io.BytesIO(b""))
for url in publish_api.DEFAULT_ENDPOINTS]
with patch("publish_api.urllib.request.urlopen", side_effect=errors):
publish_api.check_sparkle_endpoints(list(publish_api.DEFAULT_ENDPOINTS))
def test_verify_fetches_each_region_with_release_probe_and_compares_bytes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
feed = Path(directory) / "XStats-1.0.0-AppleSilicon.xml"
feed.write_bytes(b"<rss>signed</rss>")
served = [b"<rss>signed</rss>", b"<rss>signed</rss>"]
responses = [contextlib.nullcontext(type("Response", (), {"status": 200, "read": lambda self, limit, body=body: body})())
for body in served]
with patch("publish_api.urllib.request.urlopen", side_effect=responses) as send, \
contextlib.redirect_stdout(io.StringIO()):
publish_api.verify_sparkle_feeds(feed, list(publish_api.DEFAULT_ENDPOINTS))
urls = [call.args[0].full_url for call in send.call_args_list]
self.assertEqual(urls, [
"https://xstats-apps.12306.work/api/v1/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64,
"https://x-stats.china.12306.work/api/v1/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64,
])
for call in send.call_args_list:
self.assertFalse(call.args[0].has_header("Authorization"))
def test_verify_rejects_region_serving_different_feed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
feed = Path(directory) / "XStats-1.0.0-AppleSilicon.xml"
feed.write_bytes(b"<rss>signed</rss>")
stale = type("Response", (), {"status": 200, "read": lambda self, limit: b"<rss>previous</rss>"})()
ok = type("Response", (), {"status": 200, "read": lambda self, limit: b"<rss>signed</rss>"})()
with patch("publish_api.urllib.request.urlopen",
side_effect=[contextlib.nullcontext(ok), contextlib.nullcontext(stale)]), \
contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(RuntimeError, "x-stats.china.12306.work"):
publish_api.verify_sparkle_feeds(feed, list(publish_api.DEFAULT_ENDPOINTS))
def test_sparkle_gate_rejects_old_server_or_cached_validation(self) -> None:
for status, headers in ((404, {}), (400, {}), (503, {"Cache-Control": "no-store"})):
error = urllib.error.HTTPError("https://example.test", status, "error", headers, io.BytesIO(b""))
with patch("publish_api.urllib.request.urlopen", side_effect=error):
with self.assertRaises(RuntimeError):
publish_api.check_sparkle_endpoints(list(publish_api.DEFAULT_ENDPOINTS))
def test_sends_manifest_with_bearer_token(self) -> None:
received = []
class Handler(BaseHTTPRequestHandler):
def do_PUT(self) -> None:
received.append({
"path": self.path,
"authorization": self.headers.get("Authorization"),
"content_type": self.headers.get("Content-Type"),
"body": json.loads(self.rfile.read(int(self.headers["Content-Length"]))),
})
self.send_response(204)
self.end_headers()
def log_message(self, *_: object) -> None:
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with tempfile.TemporaryDirectory() as directory:
archive = Path(directory) / "XStats-0.14.1-AppleSilicon.zip"
archive.write_bytes(b"legacy and Sparkle share the same archive")
dmg = Path(directory) / "XStats-0.14.1-AppleSilicon.dmg"
# JSON 顶层字段继续供旧客户端解码;Sparkle 元数据留在独立的 XML。
manifest = {
"version": "0.14.1", "build": "126", "date": "2026-09-30",
"minimumSystem": "14.0",
**appcast.asset("https://example.test/download", str(archive), str(dmg)),
"notes": ["支持 Sparkle 更新"],
"changelog": "https://github.com/ysicing/xstats/blob/main/CHANGELOG.md",
}
path = Path(directory) / "appcast.json"
path.write_text(json.dumps(manifest), encoding="utf-8")
base = f"http://127.0.0.1:{server.server_port}"
with contextlib.redirect_stdout(io.StringIO()):
publish_api.publish(path, [f"{base}/global", f"{base}/china"], "release-secret")
finally:
server.shutdown()
thread.join()
server.server_close()
self.assertEqual([item["path"] for item in received], ["/global", "/china"])
for item in received:
self.assertEqual(item["authorization"], "Bearer release-secret")
self.assertEqual(item["content_type"], "application/json")
self.assertEqual(item["body"], manifest)
self.assertTrue(item["body"]["url"].endswith("-AppleSilicon.zip"))
self.assertEqual(len(item["body"]["sha256"]), 64)
self.assertIsInstance(item["body"]["size"], int)
self.assertIsInstance(item["body"]["build"], str)
if __name__ == "__main__":
unittest.main()