// Copyright (C) 2026 ysicing // SPDX-License-Identifier: AGPL-3.0-or-later package main import ( "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "regexp" "strings" "testing" "github.com/gofiber/fiber/v3" "github.com/libtnb/sqlite" "gorm.io/gorm" ) type feedTransport func(*http.Request) (*http.Response, error) func (f feedTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } // 已签名的固定夹具避免在 CI 中读取发布私钥;安装包签名只作结构测试。 func signedSparkleFixture(t *testing.T, version string) string { t.Helper() data, err := os.ReadFile("testdata/sparkle-" + version + ".xml") if err != nil { t.Fatal(err) } 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)) req := httptest.NewRequest(http.MethodPut, "/api/v1/releases/current", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer release-secret") res, err := app.Test(req) if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusNoContent { t.Fatalf("publish: %d", res.StatusCode) } } func TestSparkleStableFeedPreservesBytesAndTracksOncePerGet(t *testing.T) { bytes := signedSparkleFixture(t, "0.14.1") var urls []string client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) { urls = append(urls, r.URL.String()) if r.URL.RawQuery != "" { t.Fatal("installation identifier forwarded to CDN") } return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(bytes)), 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) } endpoint := "/api/v1/update/appcast.xml?current_version=0.14.0&installation_id=" + strings.Repeat("b", 64) for index, version := range []string{"0.14.1", "0.14.2"} { publishSparkleFixture(t, app, version, fmt.Sprint(126+index)) bytes = signedSparkleFixture(t, version) res, err := app.Test(httptest.NewRequest(http.MethodGet, endpoint, nil)) if err != nil { t.Fatal(err) } data, _ := io.ReadAll(res.Body) res.Body.Close() if res.StatusCode != 200 || string(data) != bytes || res.Header.Get("Cache-Control") != "no-store" { t.Fatalf("feed: %d %s", res.StatusCode, data) } } if strings.Join(urls, ",") != "https://example.test/XStats-0.14.1.xml,https://example.test/XStats-0.14.2.xml" { t.Fatal(urls) } 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 record installation if err := database.First(&record, "id = ?", strings.Repeat("b", 64)).Error; err != nil { t.Fatal(err) } if record.Checks != 2 { t.Fatalf("GET count = %d", record.Checks) } req := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(`{"current_version":"0.14.0","installation_id":"`+strings.Repeat("b", 64)+`"}`)) req.Header.Set("Content-Type", "application/json") res, err := app.Test(req) if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != 200 { t.Fatal(res.StatusCode) } if err := database.First(&record, "id = ?", strings.Repeat("b", 64)).Error; err != nil { t.Fatal(err) } var count int64 if err := database.Model(&installation{}).Count(&count).Error; err != nil { t.Fatal(err) } if count != 1 || record.Checks != 3 { t.Fatalf("legacy/new telemetry: rows %d, checks %d", count, record.Checks) } } func TestSparkleFeedRejectsInvalidInputsAndBoundedUpstreamFailures(t *testing.T) { for _, body := range []string{"", strings.Repeat("x", 256*1024+1), "CDN error", "", "", strings.Replace(signedSparkleFixture(t, "0.14.1"), "XStats updates", "tampered", 1), signedSparkleFixture(t, "0.14.2")} { calls := 0 client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) { calls++ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), 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") for _, query := range []string{"", "?current_version=0.14.0&installation_id=serial"} { res, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/update/appcast.xml"+query, nil)) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != 400 { t.Fatal(res.StatusCode) } } if calls != 0 { t.Fatal("invalid request reached CDN") } res, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/update/appcast.xml?current_version=0.14.0&installation_id="+strings.Repeat("a", 64), nil)) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != 502 { t.Fatal(res.StatusCode) } 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") res, err = app.Test(legacy) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != 200 { t.Fatalf("legacy update: %d", res.StatusCode) } } } // 保持同一份有效签名,改变发布指针的各项元数据必须被拒绝。 func TestSparkleFeedValidatesPublishedMetadataAndSignature(t *testing.T) { feed := signedSparkleFixture(t, "0.14.1") published := release{Version: "0.14.1", Build: "126", MinimumSystem: "14.0", URL: "https://example.test/XStats-0.14.1.zip", Size: 1000, SHA256: strings.Repeat("a", 64)} if err := validateSparkleFeed([]byte(feed), published); err != nil { t.Fatal(err) } for _, field := range []string{"version", "build", "minimumSystem", "url", "size", "sha256"} { t.Run(field, func(t *testing.T) { altered := published switch field { case "version": altered.Version = "0.14.2" case "build": altered.Build = "127" case "minimumSystem": altered.MinimumSystem = "15.0" case "url": altered.URL = "https://example.test/other.zip" case "size": altered.Size++ case "sha256": altered.SHA256 = strings.Repeat("b", 64) } if validateSparkleFeed([]byte(feed), altered) == nil { t.Fatal("mismatch accepted") } }) } signatureStart := strings.LastIndex(feed, "