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>
This commit is contained in:
1 parent
3dd5aa3e6c
commit
1623effd61
7 files changed
+233
-33
No files matched your search
@@ -137,7 +137,7 @@ TAP=ysicing/homebrew-tap
|
||||
Its required order is:
|
||||
|
||||
1. Verify provenance, clean worktree, and `HEAD == origin/main`.
|
||||
Before uploading or publishing a Sparkle client, run `python3 scripts/publish_api.py --check-sparkle`. Both regional fixed XML endpoints must already be deployed and reject a missing installation ID with 400 + no-store. Old JSON POST clients and publication fields remain unchanged. The API serves the current manifest's immutable signed XML without rewriting its signature or forwarding the installation ID to the CDN.
|
||||
Before uploading or publishing a Sparkle client, run `python3 scripts/publish_api.py --check-sparkle`. After the JSON manifest is published, `python3 scripts/publish_api.py --verify-sparkle dist/XStats-<version>-AppleSilicon.xml` fetches each regional XML endpoint with the reserved probe installation ID (64 zeros, not counted in statistics) and requires the bytes to match the local signed XML; `publish_release.sh` runs it automatically. Both regional fixed XML endpoints must already be deployed and reject a missing installation ID with 400 + no-store. Old JSON POST clients and publication fields remain unchanged. The API serves the current manifest's immutable signed XML without rewriting its signature or forwarding the installation ID to the CDN.
|
||||
|
||||
2. Verify Ed25519 signatures, then upload DMG, ZIP and versioned XML to `c-ip`.
|
||||
3. Download each public `c.ysicing.net` URL and compare SHA-256.
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ git push
|
||||
```
|
||||
|
||||
发布需要本地装有 `mc`(MinIO 客户端,别名 `c-ip` 指向对象存储源站)和 `gh`(GitHub CLI)。
|
||||
安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端在 256 KiB 上限内核验 Ed25519 签名、RSS 结构和与当前发布一致的版本、构建号、系统要求及安装包元数据,成功后原样代理版本文件,合并统计并禁止缓存。无效内容返回 502,不增加检查统计;旧 JSON 接口独立工作。后端公钥须与 `App/Info.plist` 的 `SUPublicEDKey` 一致,轮换发布密钥时同步更新两套后端。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg,
|
||||
安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端在 256 KiB 上限内核验 Ed25519 签名、RSS 结构和与当前发布一致的版本、构建号、系统要求及安装包元数据,成功后原样代理版本文件,合并统计并禁止缓存。无效内容返回 502,不增加检查统计;旧 JSON 接口独立工作。后端公钥须与 `App/Info.plist` 的 `SUPublicEDKey` 一致(`go test` 会校验),轮换发布密钥时同步更新两套后端。JSON 发布后,`publish_api.py --verify-sparkle` 以保留探测标识(64 个 `0`,不计入统计)逐区域取回 XML,须与本地已签名文件逐字节一致。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg,
|
||||
作为应用内「手动下载」和 README 的下载入口。版本徽章需在四个 README 的第 9 行手动更新,
|
||||
`scripts/sync_changelog.py` 不处理徽章。
|
||||
|
||||
|
||||
+39
-9
@@ -20,25 +20,51 @@ DEFAULT_ENDPOINTS = (
|
||||
)
|
||||
|
||||
|
||||
# 与服务端 releaseProbeInstallationID 一致:核验更新源但不计入安装统计。
|
||||
RELEASE_PROBE_INSTALLATION_ID = "0" * 64
|
||||
MAXIMUM_FEED_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def sparkle_feed_url(endpoint: str, query: str = "") -> str:
|
||||
parts = urlsplit(endpoint)
|
||||
if not parts.path.endswith("/api/v1/releases/current"):
|
||||
raise ValueError(f"无法推导 Sparkle 更新源:{endpoint}")
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path.removesuffix("releases/current") + "update/appcast.xml", query, ""))
|
||||
|
||||
|
||||
def check_sparkle_endpoints(endpoints: list[str]) -> None:
|
||||
"""不携带安装标识的探测必须返回 400,防止先发布客户端而服务端仍是旧版。"""
|
||||
if not endpoints:
|
||||
raise ValueError("至少需要一个版本发布接口")
|
||||
for endpoint in endpoints:
|
||||
parts = urlsplit(endpoint)
|
||||
if not parts.path.endswith("/api/v1/releases/current"):
|
||||
raise ValueError(f"无法推导 Sparkle 更新源:{endpoint}")
|
||||
url = urlunsplit((parts.scheme, parts.netloc, parts.path.removesuffix("releases/current") + "update/appcast.xml", "", ""))
|
||||
url = sparkle_feed_url(endpoint)
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "XStats-Release"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
raise RuntimeError(f"{url} 未拒绝缺少安装标识的请求:{response.status}")
|
||||
except urllib.error.HTTPError as error:
|
||||
with error:
|
||||
if error.code != 400 or error.headers.get("Cache-Control") != "no-store":
|
||||
# 代理可能合并缓存指令,按 token 判断而不是整串比较。
|
||||
directives = {item.strip().lower() for item in (error.headers.get("Cache-Control") or "").split(",")}
|
||||
if error.code != 400 or "no-store" not in directives:
|
||||
raise RuntimeError(f"请先部署支持 Sparkle 的 API:{url} 返回 {error.code}") from error
|
||||
|
||||
|
||||
def verify_sparkle_feeds(feed: Path, endpoints: list[str]) -> None:
|
||||
"""发布 JSON 后逐区域取回 Sparkle 清单,必须与本地已签名 XML 逐字节一致。"""
|
||||
if not endpoints:
|
||||
raise ValueError("至少需要一个版本发布接口")
|
||||
expected = feed.read_bytes()
|
||||
query = f"current_version=release-probe&installation_id={RELEASE_PROBE_INSTALLATION_ID}"
|
||||
for endpoint in endpoints:
|
||||
url = sparkle_feed_url(endpoint, query)
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "XStats-Release"})
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
if response.status != 200 or response.read(MAXIMUM_FEED_BYTES + 1) != expected:
|
||||
raise RuntimeError(f"{url} 返回的 Sparkle 清单与 {feed.name} 不一致")
|
||||
print(f"✅ Sparkle 更新源已生效:{urlsplit(url).netloc}")
|
||||
|
||||
|
||||
def publish(appcast: Path, endpoints: list[str], token: str) -> None:
|
||||
if not token:
|
||||
raise ValueError("XSTATS_RELEASE_TOKEN 不能为空")
|
||||
@@ -75,15 +101,19 @@ def configured_endpoints(environment: Mapping[str, str]) -> list[str]:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit(f"用法:{sys.argv[0]} <appcast.json>")
|
||||
usage = f"用法:{sys.argv[0]} <appcast.json> | --check-sparkle | --verify-sparkle <feed.xml>"
|
||||
arguments = sys.argv[1:]
|
||||
if len(arguments) not in (1, 2) or (len(arguments) == 2) != (arguments[0] == "--verify-sparkle"):
|
||||
raise SystemExit(usage)
|
||||
endpoints = configured_endpoints(os.environ)
|
||||
token = os.environ.get("XSTATS_RELEASE_TOKEN", "")
|
||||
try:
|
||||
if sys.argv[1] == "--check-sparkle":
|
||||
if arguments[0] == "--check-sparkle":
|
||||
check_sparkle_endpoints(endpoints)
|
||||
elif arguments[0] == "--verify-sparkle":
|
||||
verify_sparkle_feeds(Path(arguments[1]), endpoints)
|
||||
else:
|
||||
publish(Path(sys.argv[1]), endpoints, token)
|
||||
publish(Path(arguments[0]), endpoints, token)
|
||||
except Exception as error:
|
||||
raise SystemExit(f"发布版本到 API 失败:{error}") from error
|
||||
|
||||
|
||||
@@ -39,6 +39,42 @@ class PublishAPITest(unittest.TestCase):
|
||||
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""))
|
||||
|
||||
@@ -95,6 +95,8 @@ echo "✅ https://github.com/ysicing/xstats/releases/tag/v${VERSION}"
|
||||
# 清单最后提交:安装包已在对象存储就位并校验通过后,已安装的应用才会看到新版本。
|
||||
# Token 只从环境变量读取,不出现在命令参数里。
|
||||
python3 scripts/publish_api.py "$APPCAST"
|
||||
# JSON 生效后逐区域取回 Sparkle 清单,确认服务端验签通过且与本地 XML 一致;保留探测标识不计入统计。
|
||||
python3 scripts/publish_api.py --verify-sparkle "dist/XStats-${VERSION}-AppleSilicon.xml"
|
||||
|
||||
# Homebrew tap
|
||||
WORK="$(mktemp -d)"
|
||||
|
||||
+17
-9
@@ -26,6 +26,9 @@ import (
|
||||
|
||||
var installationIDPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
|
||||
// 发布脚本端到端核验更新源时使用的保留标识;清单照常校验返回,但不计入安装统计。
|
||||
var releaseProbeInstallationID = strings.Repeat("0", 64)
|
||||
|
||||
func validHTTPS(raw string, optional bool) bool {
|
||||
if raw == "" {
|
||||
return optional
|
||||
@@ -110,16 +113,19 @@ func loadConfig(getenv func(string) string) (config, error) {
|
||||
|
||||
func newApplication(databasePath string, releaseToken string) (*fiber.App, error) {
|
||||
return newApplicationWithFeedClient(databasePath, releaseToken, &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if request.URL.Scheme != "https" || len(via) >= 5 {
|
||||
return errors.New("invalid feed redirect")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: feedRedirectPolicy,
|
||||
})
|
||||
}
|
||||
|
||||
// 更新清单只允许有限次 HTTPS 跳转,降级到 HTTP 或跳转过多都视为上游故障。
|
||||
func feedRedirectPolicy(request *http.Request, via []*http.Request) error {
|
||||
if request.URL.Scheme != "https" || len(via) >= 5 {
|
||||
return errors.New("invalid feed redirect")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newApplicationWithFeedClient(databasePath string, releaseToken string, feedClient *http.Client) (*fiber.App, error) {
|
||||
databaseLogger := gormlogger.New(log.Default(), gormlogger.Config{
|
||||
SlowThreshold: 500 * time.Millisecond,
|
||||
@@ -237,8 +243,10 @@ func newApplicationWithFeedClient(databasePath string, releaseToken string, feed
|
||||
if err := validateSparkleFeed(data, current); err != nil {
|
||||
return c.SendStatus(http.StatusBadGateway)
|
||||
}
|
||||
if err := recordUpdateCheck(database, check); err != nil {
|
||||
return c.SendStatus(http.StatusInternalServerError)
|
||||
if check.InstallationID != releaseProbeInstallationID {
|
||||
if err := recordUpdateCheck(database, check); err != nil {
|
||||
return c.SendStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
c.Type("xml", "utf-8")
|
||||
return c.Send(data)
|
||||
|
||||
+137
-13
@@ -4,11 +4,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -31,6 +33,24 @@ func signedSparkleFixture(t *testing.T, version string) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func trackedInstallations(t *testing.T, databasePath string) int64 {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection, err := database.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
var count int64
|
||||
if err := database.Model(&installation{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func publishSparkleFixture(t *testing.T, app *fiber.App, version, build string) {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"version":%q,"build":%q,"date":"2026-09-30","minimumSystem":"14.0","url":"https://example.test/XStats-%s.zip","sha256":"%s","size":1000,"notes":["test"]}`, version, build, version, strings.Repeat("a", 64))
|
||||
@@ -151,19 +171,8 @@ func TestSparkleFeedRejectsInvalidInputsAndBoundedUpstreamFailures(t *testing.T)
|
||||
if res.StatusCode != 502 {
|
||||
t.Fatal(res.StatusCode)
|
||||
}
|
||||
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connection, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
err = db.Model(&installation{}).Count(&count).Error
|
||||
connection.Close()
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("invalid feed tracked %d: %v", count, err)
|
||||
if count := trackedInstallations(t, databasePath); count != 0 {
|
||||
t.Fatalf("invalid feed tracked %d", count)
|
||||
}
|
||||
legacy := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(`{"current_version":"0.12.1","installation_id":"`+strings.Repeat("a", 64)+`"}`))
|
||||
legacy.Header.Set("Content-Type", "application/json")
|
||||
@@ -215,3 +224,118 @@ func TestSparkleFeedValidatesPublishedMetadataAndSignature(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生产客户端的跳转策略与上游故障都必须返回 502,且不计入检查统计。
|
||||
func TestSparkleFeedUpstreamFailuresUseProductionRedirectPolicy(t *testing.T) {
|
||||
feed := signedSparkleFixture(t, "0.14.1")
|
||||
respond := func(r *http.Request, status int, location, body string) *http.Response {
|
||||
header := make(http.Header)
|
||||
if location != "" {
|
||||
header.Set("Location", location)
|
||||
}
|
||||
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: header, Request: r}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
tracked int64
|
||||
requests int
|
||||
reply func(*http.Request) (*http.Response, error)
|
||||
}{
|
||||
{"https redirect", 200, 1, 2, func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/final.xml" {
|
||||
return respond(r, 200, "", feed), nil
|
||||
}
|
||||
return respond(r, 302, "https://cdn.example.test/final.xml", ""), nil
|
||||
}},
|
||||
{"http downgrade", 502, 0, 1, func(r *http.Request) (*http.Response, error) {
|
||||
return respond(r, 302, "http://example.test/XStats-0.14.1.xml", ""), nil
|
||||
}},
|
||||
{"redirect loop", 502, 0, 5, func(r *http.Request) (*http.Response, error) {
|
||||
return respond(r, 302, "https://example.test/loop", ""), nil
|
||||
}},
|
||||
{"not found", 502, 0, 1, func(r *http.Request) (*http.Response, error) {
|
||||
return respond(r, 404, "", feed), nil
|
||||
}},
|
||||
{"transport error", 502, 0, 1, func(r *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection reset")
|
||||
}},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var requests []string
|
||||
transport := feedTransport(func(r *http.Request) (*http.Response, error) {
|
||||
requests = append(requests, r.URL.String())
|
||||
return test.reply(r)
|
||||
})
|
||||
client := &http.Client{Transport: transport, CheckRedirect: feedRedirectPolicy}
|
||||
databasePath := t.TempDir() + "/xstats.sqlite"
|
||||
app, err := newApplicationWithFeedClient(databasePath, "release-secret", client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publishSparkleFixture(t, app, "0.14.1", "126")
|
||||
res, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/update/appcast.xml?current_version=0.14.0&installation_id="+strings.Repeat("c", 64), nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != test.status {
|
||||
t.Fatalf("status %d, want %d", res.StatusCode, test.status)
|
||||
}
|
||||
if count := trackedInstallations(t, databasePath); count != test.tracked {
|
||||
t.Fatalf("tracked %d, want %d", count, test.tracked)
|
||||
}
|
||||
// 策略必须在发出 HTTP 请求或超过 5 次跳转之前拦截,而不只依赖处理函数兜底。
|
||||
if len(requests) != test.requests {
|
||||
t.Fatalf("upstream requests %v, want %d", requests, test.requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端验签公钥必须与客户端内置公钥一致,轮换时漏改会让所有 Sparkle 检查返回 502。
|
||||
func TestSparklePublicKeyMatchesClient(t *testing.T) {
|
||||
sources := map[string]*regexp.Regexp{
|
||||
"../../App/Info.plist": regexp.MustCompile(`<key>SUPublicEDKey</key>\s*<string>([^<]+)</string>`),
|
||||
"../../project.yml": regexp.MustCompile(`SUPublicEDKey:\s*"([^"]+)"`),
|
||||
}
|
||||
for path, pattern := range sources {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
match := pattern.FindSubmatch(data)
|
||||
if match == nil {
|
||||
t.Fatalf("%s: SUPublicEDKey not found", path)
|
||||
}
|
||||
if string(match[1]) != sparklePublicKey {
|
||||
t.Fatalf("%s: SUPublicEDKey %s != server %s", path, match[1], sparklePublicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparkleReleaseProbeReturnsFeedWithoutTracking(t *testing.T) {
|
||||
feed := signedSparkleFixture(t, "0.14.1")
|
||||
client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(feed)), Header: make(http.Header), Request: r}, nil
|
||||
})}
|
||||
databasePath := t.TempDir() + "/xstats.sqlite"
|
||||
app, err := newApplicationWithFeedClient(databasePath, "release-secret", client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publishSparkleFixture(t, app, "0.14.1", "126")
|
||||
res, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/update/appcast.xml?current_version=0.14.1&installation_id="+releaseProbeInstallationID, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if res.StatusCode != 200 || string(data) != feed {
|
||||
t.Fatalf("probe: %d", res.StatusCode)
|
||||
}
|
||||
if count := trackedInstallations(t, databasePath); count != 0 {
|
||||
t.Fatalf("release probe tracked %d", count)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user