Merge pull request #3881 from Wei-Shaw/feat/version-rollback

feat: 版本徽章新增近3个历史版本在线回退与手动回退指引
This commit is contained in:
Wesley Liddick
2026-07-09 22:39:41 +08:00
committed by GitHub
12 changed files with 1064 additions and 37 deletions
@@ -26,6 +26,8 @@ type systemUpdateService interface {
CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error)
PerformUpdate(ctx context.Context) error
Rollback() error
ListRollbackVersions(ctx context.Context) ([]service.RollbackVersion, error)
RollbackToVersion(ctx context.Context, version string) error
}
// NewSystemHandler creates a new SystemHandler
@@ -102,11 +104,42 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) {
})
}
// Rollback restores the previous version
// GetRollbackVersions lists versions available for rollback
// GET /api/v1/admin/system/rollback-versions
func (h *SystemHandler) GetRollbackVersions(c *gin.Context) {
versions, err := h.updateSvc.ListRollbackVersions(c.Request.Context())
if err != nil {
response.Error(c, http.StatusInternalServerError, err.Error())
return
}
response.Success(c, gin.H{
"versions": versions,
})
}
// Rollback restores a previous version.
// Without a body (or with an empty version) it restores the local .backup binary
// left by the last in-place update. With {"version": "x.y.z"} it downloads and
// installs that specific release (must be one of the recent rollback versions).
// POST /api/v1/admin/system/rollback
func (h *SystemHandler) Rollback(c *gin.Context) {
operationID := buildSystemOperationID(c, "rollback")
payload := gin.H{"operation_id": operationID}
var req struct {
Version string `json:"version"`
}
if c.Request.Body != nil && c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "invalid request body")
return
}
}
targetVersion := strings.TrimSpace(req.Version)
operation := "rollback"
if targetVersion != "" {
operation = "rollback:" + targetVersion
}
operationID := buildSystemOperationID(c, operation)
payload := gin.H{"operation_id": operationID, "version": targetVersion}
executeAdminIdempotentJSON(c, "admin.system.rollback", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) {
lock, release, err := h.acquireSystemLock(ctx, operationID)
if err != nil {
@@ -118,7 +151,12 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
release(releaseReason, succeeded)
}()
if err := h.updateSvc.Rollback(); err != nil {
if targetVersion != "" {
err = h.updateSvc.RollbackToVersion(ctx, targetVersion)
} else {
err = h.updateSvc.Rollback()
}
if err != nil {
releaseReason = "SYSTEM_ROLLBACK_FAILED"
return nil, err
}
@@ -127,6 +165,7 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
return gin.H{
"message": "Rollback completed. Please restart the service.",
"need_restart": true,
"version": targetVersion,
"operation_id": lock.OperationID(),
}, nil
})
@@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -17,11 +18,18 @@ import (
)
type systemHandlerUpdateServiceStub struct {
performErr error
updateInfo *service.UpdateInfo
checkErr error
checkForces []bool
performCall int
performErr error
updateInfo *service.UpdateInfo
checkErr error
checkForces []bool
performCall int
rollbackCall int
rollbackToCall int
rollbackToVersions []string
rollbackToErr error
rollbackVersions []service.RollbackVersion
rollbackVersionsErr error
rollbackVersionsCall int
}
func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) {
@@ -35,9 +43,21 @@ func (s *systemHandlerUpdateServiceStub) PerformUpdate(context.Context) error {
}
func (s *systemHandlerUpdateServiceStub) Rollback() error {
s.rollbackCall++
return nil
}
func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) ([]service.RollbackVersion, error) {
s.rollbackVersionsCall++
return s.rollbackVersions, s.rollbackVersionsErr
}
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(_ context.Context, version string) error {
s.rollbackToCall++
s.rollbackToVersions = append(s.rollbackToVersions, version)
return s.rollbackToErr
}
type systemUpdateResponseEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -71,6 +91,8 @@ func newSystemHandlerTestRouter(t *testing.T, updateSvc *systemHandlerUpdateServ
router := gin.New()
router.POST("/api/v1/admin/system/update", handler.PerformUpdate)
router.POST("/api/v1/admin/system/rollback", handler.Rollback)
router.GET("/api/v1/admin/system/rollback-versions", handler.GetRollbackVersions)
return router
}
@@ -142,3 +164,104 @@ func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T
require.Equal(t, http.StatusInternalServerError, body.Code)
require.Equal(t, "internal error", body.Message)
}
func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback", nil)
req.Header.Set("Idempotency-Key", "legacy-rollback")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, updateSvc.rollbackCall)
require.Equal(t, 0, updateSvc.rollbackToCall)
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
}
func TestSystemHandlerRollbackWithVersionCallsRollbackToVersion(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
strings.NewReader(`{"version":"0.1.146"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "rollback-to-146")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 0, updateSvc.rollbackCall)
require.Equal(t, 1, updateSvc.rollbackToCall)
require.Equal(t, []string{"0.1.146"}, updateSvc.rollbackToVersions)
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
var body systemUpdateResponseEnvelope
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, 0, body.Code)
require.Equal(t, "Rollback completed. Please restart the service.", body.Data.Message)
}
func TestSystemHandlerRollbackWithDisallowedVersionReturnsBadRequest(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{
rollbackToErr: service.ErrRollbackVersionNotAllowed,
}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
strings.NewReader(`{"version":"9.9.9"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "rollback-to-bad")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, 1, updateSvc.rollbackToCall)
}
func TestSystemHandlerGetRollbackVersions(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{
rollbackVersions: []service.RollbackVersion{
{Version: "0.1.146", PublishedAt: "2026-07-07T00:00:00Z", HTMLURL: "https://example.com/v0.1.146"},
{Version: "0.1.145", PublishedAt: "2026-07-06T00:00:00Z", HTMLURL: "https://example.com/v0.1.145"},
},
}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, updateSvc.rollbackVersionsCall)
var body struct {
Code int `json:"code"`
Data struct {
Versions []service.RollbackVersion `json:"versions"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, 0, body.Code)
require.Len(t, body.Data.Versions, 2)
require.Equal(t, "0.1.146", body.Data.Versions[0].Version)
}
func TestSystemHandlerGetRollbackVersionsError(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{
rollbackVersionsErr: errors.New("github unavailable"),
}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusInternalServerError, rec.Code)
}
@@ -67,6 +67,10 @@ func (c *githubReleaseClientError) FetchLatestRelease(ctx context.Context, repo
return nil, c.err
}
func (c *githubReleaseClientError) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) {
return nil, c.err
}
func (c *githubReleaseClientError) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
return c.err
}
@@ -103,6 +107,40 @@ func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo strin
return &release, nil
}
func (c *githubReleaseClient) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) {
if perPage <= 0 {
perPage = 10
}
if perPage > 100 {
perPage = 100 // GitHub API hard limit
}
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, perPage)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "Sub2API-Updater")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
var releases []*service.GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}
return releases, nil
}
func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
@@ -29,6 +29,9 @@ type testTransport struct {
func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Rewrite the URL to point to our test server
testURL := t.testServerURL + req.URL.Path
if req.URL.RawQuery != "" {
testURL += "?" + req.URL.RawQuery
}
newReq, err := http.NewRequestWithContext(req.Context(), req.Method, testURL, req.Body)
if err != nil {
return nil, err
@@ -245,6 +248,76 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Success() {
require.Equal(s.T(), "app-linux-amd64.tar.gz", release.Assets[0].Name)
}
func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Success() {
releasesJSON := `[
{
"tag_name": "v1.0.1",
"name": "Release 1.0.1",
"html_url": "https://github.com/test/repo/releases/v1.0.1",
"published_at": "2026-07-08T00:00:00Z",
"prerelease": false,
"assets": [
{
"name": "app-linux-amd64.tar.gz",
"browser_download_url": "https://github.com/test/repo/releases/download/v1.0.1/app-linux-amd64.tar.gz"
}
]
},
{
"tag_name": "v1.0.1-rc1",
"name": "Release 1.0.1-rc1",
"prerelease": true
},
{
"tag_name": "v1.0.0",
"name": "Release 1.0.0"
}
]`
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(s.T(), "/repos/test/repo/releases", r.URL.Path)
require.Equal(s.T(), "15", r.URL.Query().Get("per_page"))
require.Equal(s.T(), "application/vnd.github.v3+json", r.Header.Get("Accept"))
require.Equal(s.T(), "Sub2API-Updater", r.Header.Get("User-Agent"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(releasesJSON))
}))
s.client = &githubReleaseClient{
httpClient: &http.Client{
Transport: &testTransport{testServerURL: s.srv.URL},
},
downloadHTTPClient: &http.Client{},
}
releases, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15)
require.NoError(s.T(), err)
require.Len(s.T(), releases, 3)
require.Equal(s.T(), "v1.0.1", releases[0].TagName)
require.False(s.T(), releases[0].Prerelease)
require.Len(s.T(), releases[0].Assets, 1)
require.True(s.T(), releases[1].Prerelease)
require.Equal(s.T(), "v1.0.0", releases[2].TagName)
}
func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Non200() {
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
s.client = &githubReleaseClient{
httpClient: &http.Client{
Transport: &testTransport{testServerURL: s.srv.URL},
},
downloadHTTPClient: &http.Client{},
}
_, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15)
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "403")
}
func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Non200() {
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
+1
View File
@@ -545,6 +545,7 @@ func registerSystemRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
{
system.GET("/version", h.Admin.System.GetVersion)
system.GET("/check-updates", h.Admin.System.CheckUpdates)
system.GET("/rollback-versions", h.Admin.System.GetRollbackVersions)
system.POST("/update", h.Admin.System.PerformUpdate)
system.POST("/rollback", h.Admin.System.Rollback)
system.POST("/restart", h.Admin.System.RestartService)
+122 -2
View File
@@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
@@ -22,7 +23,8 @@ import (
)
var (
ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest")
ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest")
ErrRollbackVersionNotAllowed = infraerrors.BadRequest("ROLLBACK_VERSION_NOT_ALLOWED", "version is not in the allowed rollback list")
)
const (
@@ -36,6 +38,11 @@ const (
// Security: max download size (500MB)
maxDownloadSize = 500 * 1024 * 1024
// Rollback: expose at most the 3 most recent versions older than current
maxRollbackVersions = 3
// Fetch a few extra releases so filtering (current/newer/prerelease) still leaves enough candidates
rollbackFetchPageSize = 15
)
// UpdateCache defines cache operations for update service
@@ -47,6 +54,7 @@ type UpdateCache interface {
// GitHubReleaseClient 获取 GitHub release 信息的接口
type GitHubReleaseClient interface {
FetchLatestRelease(ctx context.Context, repo string) (*GitHubRelease, error)
FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*GitHubRelease, error)
DownloadFile(ctx context.Context, url, dest string, maxSize int64) error
FetchChecksumFile(ctx context.Context, url string) ([]byte, error)
}
@@ -103,9 +111,18 @@ type GitHubRelease struct {
Body string `json:"body"`
PublishedAt string `json:"published_at"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Assets []GitHubAsset `json:"assets"`
}
// RollbackVersion describes a release version the system can roll back to
type RollbackVersion struct {
Version string `json:"version"` // without "v" prefix, e.g. "0.1.146"
PublishedAt string `json:"published_at"`
HTMLURL string `json:"html_url"`
}
type GitHubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
@@ -155,12 +172,19 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
return ErrNoUpdateAvailable
}
return s.applyReleaseAssets(ctx, info.ReleaseInfo.Assets)
}
// applyReleaseAssets downloads the platform archive from the given release assets,
// verifies its checksum, and atomically swaps the running binary.
// Shared by PerformUpdate (latest) and RollbackToVersion (specific older version).
func (s *UpdateService) applyReleaseAssets(ctx context.Context, releaseAssets []Asset) error {
// Find matching archive and checksum for current platform
archiveName := s.getArchiveName()
var downloadURL string
var checksumURL string
for _, asset := range info.ReleaseInfo.Assets {
for _, asset := range releaseAssets {
if strings.Contains(asset.Name, archiveName) && !strings.HasSuffix(asset.Name, ".txt") {
downloadURL = asset.DownloadURL
}
@@ -279,6 +303,102 @@ func (s *UpdateService) Rollback() error {
return nil
}
// ListRollbackVersions returns up to maxRollbackVersions release versions that are
// strictly older than the current version (the current version itself is excluded),
// newest first. Draft and prerelease entries are skipped.
func (s *UpdateService) ListRollbackVersions(ctx context.Context) ([]RollbackVersion, error) {
releases, err := s.fetchRollbackCandidates(ctx)
if err != nil {
return nil, err
}
versions := make([]RollbackVersion, 0, len(releases))
for _, r := range releases {
versions = append(versions, RollbackVersion{
Version: strings.TrimPrefix(r.TagName, "v"),
PublishedAt: r.PublishedAt,
HTMLURL: r.HTMLURL,
})
}
return versions, nil
}
// RollbackToVersion downloads and installs a specific older version.
// The target must be one of the versions returned by ListRollbackVersions;
// anything else (including the current version) is rejected.
func (s *UpdateService) RollbackToVersion(ctx context.Context, version string) error {
target := strings.TrimPrefix(strings.TrimSpace(version), "v")
if target == "" {
return ErrRollbackVersionNotAllowed
}
releases, err := s.fetchRollbackCandidates(ctx)
if err != nil {
return err
}
var match *GitHubRelease
for _, r := range releases {
if strings.TrimPrefix(r.TagName, "v") == target {
match = r
break
}
}
if match == nil {
return ErrRollbackVersionNotAllowed
}
assets := make([]Asset, len(match.Assets))
for i, a := range match.Assets {
assets[i] = Asset{
Name: a.Name,
DownloadURL: a.BrowserDownloadURL,
Size: a.Size,
}
}
return s.applyReleaseAssets(ctx, assets)
}
// fetchRollbackCandidates fetches recent releases and keeps the newest
// maxRollbackVersions entries strictly older than the current version.
func (s *UpdateService) fetchRollbackCandidates(ctx context.Context) ([]*GitHubRelease, error) {
releases, err := s.githubClient.FetchRecentReleases(ctx, githubRepo, rollbackFetchPageSize)
if err != nil {
return nil, err
}
seen := make(map[string]bool, len(releases))
candidates := make([]*GitHubRelease, 0, maxRollbackVersions)
for _, r := range releases {
if r == nil || r.Draft || r.Prerelease {
continue
}
v := strings.TrimPrefix(r.TagName, "v")
if v == "" || seen[v] {
continue
}
// Only versions strictly older than current (also excludes current itself)
if compareVersions(v, s.currentVersion) >= 0 {
continue
}
seen[v] = true
candidates = append(candidates, r)
}
sort.SliceStable(candidates, func(i, j int) bool {
return compareVersions(
strings.TrimPrefix(candidates[i].TagName, "v"),
strings.TrimPrefix(candidates[j].TagName, "v"),
) > 0
})
if len(candidates) > maxRollbackVersions {
candidates = candidates[:maxRollbackVersions]
}
return candidates, nil
}
func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) {
release, err := s.githubClient.FetchLatestRelease(ctx, githubRepo)
if err != nil {
+124 -1
View File
@@ -28,13 +28,19 @@ func (s *updateServiceCacheStub) SetUpdateInfo(_ context.Context, data string, _
}
type updateServiceGitHubClientStub struct {
release *GitHubRelease
release *GitHubRelease
recentReleases []*GitHubRelease
recentErr error
}
func (s *updateServiceGitHubClientStub) FetchLatestRelease(context.Context, string) (*GitHubRelease, error) {
return s.release, nil
}
func (s *updateServiceGitHubClientStub) FetchRecentReleases(context.Context, string, int) ([]*GitHubRelease, error) {
return s.recentReleases, s.recentErr
}
func (s *updateServiceGitHubClientStub) DownloadFile(context.Context, string, string, int64) error {
panic("DownloadFile should not be called when no update is available")
}
@@ -62,3 +68,120 @@ func TestUpdateServicePerformUpdateNoUpdateReturnsSentinel(t *testing.T) {
require.True(t, errors.Is(err, ErrNoUpdateAvailable))
require.ErrorIs(t, err, ErrNoUpdateAvailable)
}
func newRollbackTestService(current string, releases []*GitHubRelease) *UpdateService {
return NewUpdateService(
&updateServiceCacheStub{},
&updateServiceGitHubClientStub{recentReleases: releases},
current,
"release",
)
}
func TestUpdateServiceListRollbackVersionsFiltersAndCaps(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "v0.1.148", PublishedAt: "2026-07-09T00:00:00Z"}, // newer than current: excluded
{TagName: "v0.1.147", PublishedAt: "2026-07-08T00:00:00Z"}, // current: excluded
{TagName: "v0.1.146-rc1", PublishedAt: "2026-07-07T12:00:00Z", Prerelease: true}, // prerelease: excluded
{TagName: "v0.1.146", PublishedAt: "2026-07-07T00:00:00Z"},
{TagName: "v0.1.145", PublishedAt: "2026-07-06T00:00:00Z", Draft: true}, // draft: excluded
{TagName: "v0.1.144", PublishedAt: "2026-07-05T00:00:00Z"},
{TagName: "v0.1.144", PublishedAt: "2026-07-05T00:00:00Z"}, // duplicate: excluded
{TagName: "v0.1.143", PublishedAt: "2026-07-04T00:00:00Z"},
{TagName: "v0.1.142", PublishedAt: "2026-07-03T00:00:00Z"}, // beyond cap of 3: excluded
}
svc := newRollbackTestService("0.1.147", releases)
versions, err := svc.ListRollbackVersions(context.Background())
require.NoError(t, err)
require.Len(t, versions, 3)
require.Equal(t, "0.1.146", versions[0].Version)
require.Equal(t, "0.1.144", versions[1].Version)
require.Equal(t, "0.1.143", versions[2].Version)
}
func TestUpdateServiceListRollbackVersionsSortsUnorderedInput(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "v0.1.144"},
{TagName: "v0.1.146"},
{TagName: "v0.1.145"},
}
svc := newRollbackTestService("0.1.147", releases)
versions, err := svc.ListRollbackVersions(context.Background())
require.NoError(t, err)
require.Len(t, versions, 3)
require.Equal(t, "0.1.146", versions[0].Version)
require.Equal(t, "0.1.145", versions[1].Version)
require.Equal(t, "0.1.144", versions[2].Version)
}
func TestUpdateServiceListRollbackVersionsEmptyWhenNoneOlder(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "v0.1.147"},
{TagName: "v0.1.148"},
}
svc := newRollbackTestService("0.1.147", releases)
versions, err := svc.ListRollbackVersions(context.Background())
require.NoError(t, err)
require.Empty(t, versions)
}
func TestUpdateServiceListRollbackVersionsPropagatesFetchError(t *testing.T) {
svc := NewUpdateService(
&updateServiceCacheStub{},
&updateServiceGitHubClientStub{recentErr: errors.New("github unavailable")},
"0.1.147",
"release",
)
_, err := svc.ListRollbackVersions(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), "github unavailable")
}
func TestUpdateServiceRollbackToVersionRejectsDisallowedTargets(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "v0.1.148"},
{TagName: "v0.1.147"},
{TagName: "v0.1.146"},
{TagName: "v0.1.145"},
{TagName: "v0.1.144"},
{TagName: "v0.1.143"},
{TagName: "v0.1.142"},
}
svc := newRollbackTestService("0.1.147", releases)
for _, target := range []string{
"", // empty
"0.1.147", // current version
"v0.1.147", // current version with prefix
"0.1.148", // newer than current
"0.1.142", // older than the 3 most recent
"9.9.9", // nonexistent
} {
err := svc.RollbackToVersion(context.Background(), target)
require.ErrorIs(t, err, ErrRollbackVersionNotAllowed, "target %q should be rejected", target)
}
}
func TestUpdateServiceRollbackToVersionAcceptsVPrefix(t *testing.T) {
// No platform asset in the release: the target passes the allowlist check
// and fails later at asset lookup, proving the version itself was accepted.
releases := []*GitHubRelease{
{TagName: "v0.1.147"},
{TagName: "v0.1.146"},
}
svc := newRollbackTestService("0.1.147", releases)
err := svc.RollbackToVersion(context.Background(), "v0.1.146")
require.Error(t, err)
require.NotErrorIs(t, err, ErrRollbackVersionNotAllowed)
require.Contains(t, err.Error(), "no compatible release found")
}