后端:
- UpdateService 新增 ListRollbackVersions/RollbackToVersion,回退目标强制
限定为比当前版本更旧的近3个正式版本(排除当前/更新/预发布/草稿),
越界返回 400 ROLLBACK_VERSION_NOT_ALLOWED;下载复用既有 HTTPS 域名
白名单 + checksum 校验 + 原子换二进制管线
- GitHubReleaseClient 新增 FetchRecentReleases(releases 列表 API)
- 新增 GET /admin/system/rollback-versions;POST /admin/system/rollback
支持可选 {"version"} body,无 body 保持原 .backup 本地回退行为不变
- 端点均在 /admin 组 adminAuth(IsAdmin+TokenVersion)保护内
前端:
- VersionBadge「已是最新版本」状态下新增版本回退面板:radio 卡片选择
近3个版本,手动回退方式按部署形态 tab 切换(脚本部署 curl 命令 /
Docker 镜像 tag 指引),支持一键复制;release 构建提供一键回退并
复用重启流程;源码构建仅提示不支持在线回退
- 下拉根元素重置 whitespace-normal,修复 sidebar-brand 的
white-space:nowrap 继承导致的长文本溢出裁切
测试:服务层过滤/排序/上限/非法目标拒绝、handler 双模式与鉴权错误映射、
GitHub client 列表拉取、前端 API 请求体行为共 16 个新增用例
391 lines
12 KiB
Go
391 lines
12 KiB
Go
package repository
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
type GitHubReleaseServiceSuite struct {
|
|
suite.Suite
|
|
srv *httptest.Server
|
|
client *githubReleaseClient
|
|
tempDir string
|
|
}
|
|
|
|
// testTransport redirects requests to the test server
|
|
type testTransport struct {
|
|
testServerURL string
|
|
}
|
|
|
|
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
|
|
}
|
|
newReq.Header = req.Header
|
|
return http.DefaultTransport.RoundTrip(newReq)
|
|
}
|
|
|
|
func newTestGitHubReleaseClient() *githubReleaseClient {
|
|
return &githubReleaseClient{
|
|
httpClient: &http.Client{},
|
|
downloadHTTPClient: &http.Client{},
|
|
}
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) SetupTest() {
|
|
s.tempDir = s.T().TempDir()
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TearDownTest() {
|
|
if s.srv != nil {
|
|
s.srv.Close()
|
|
s.srv = nil
|
|
}
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestDownloadFile_EnforcesMaxSize_ContentLength() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", "100")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(bytes.Repeat([]byte("a"), 100))
|
|
}))
|
|
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
dest := filepath.Join(s.tempDir, "file1.bin")
|
|
err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 10)
|
|
require.Error(s.T(), err, "expected error for oversized download with Content-Length")
|
|
|
|
_, statErr := os.Stat(dest)
|
|
require.Error(s.T(), statErr, "expected file to not exist for rejected download")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestDownloadFile_EnforcesMaxSize_Chunked() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Force chunked encoding (unknown Content-Length) by flushing headers before writing.
|
|
w.WriteHeader(http.StatusOK)
|
|
if fl, ok := w.(http.Flusher); ok {
|
|
fl.Flush()
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
_, _ = w.Write(bytes.Repeat([]byte("b"), 10))
|
|
if fl, ok := w.(http.Flusher); ok {
|
|
fl.Flush()
|
|
}
|
|
}
|
|
}))
|
|
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
dest := filepath.Join(s.tempDir, "file2.bin")
|
|
err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 10)
|
|
require.Error(s.T(), err, "expected error for oversized chunked download")
|
|
|
|
_, statErr := os.Stat(dest)
|
|
require.Error(s.T(), statErr, "expected file to be cleaned up for oversized chunked download")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestDownloadFile_Success() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
if fl, ok := w.(http.Flusher); ok {
|
|
fl.Flush()
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
_, _ = w.Write(bytes.Repeat([]byte("b"), 10))
|
|
if fl, ok := w.(http.Flusher); ok {
|
|
fl.Flush()
|
|
}
|
|
}
|
|
}))
|
|
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
dest := filepath.Join(s.tempDir, "file3.bin")
|
|
err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 200)
|
|
require.NoError(s.T(), err, "expected success")
|
|
|
|
b, err := os.ReadFile(dest)
|
|
require.NoError(s.T(), err, "read")
|
|
require.True(s.T(), strings.HasPrefix(string(b), "b"), "downloaded content should start with 'b'")
|
|
require.Len(s.T(), b, 100, "downloaded content length mismatch")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestDownloadFile_404() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
dest := filepath.Join(s.tempDir, "notfound.bin")
|
|
err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 100)
|
|
require.Error(s.T(), err, "expected error for 404")
|
|
|
|
_, statErr := os.Stat(dest)
|
|
require.Error(s.T(), statErr, "expected file to not exist for 404")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestFetchChecksumFile_Success() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("sum"))
|
|
}))
|
|
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
body, err := s.client.FetchChecksumFile(context.Background(), s.srv.URL)
|
|
require.NoError(s.T(), err, "FetchChecksumFile")
|
|
require.Equal(s.T(), "sum", string(body), "checksum body mismatch")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestFetchChecksumFile_Non200() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
_, err := s.client.FetchChecksumFile(context.Background(), s.srv.URL)
|
|
require.Error(s.T(), err, "expected error for non-200")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestDownloadFile_ContextCancel() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
}))
|
|
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
dest := filepath.Join(s.tempDir, "cancelled.bin")
|
|
err := s.client.DownloadFile(ctx, s.srv.URL, dest, 100)
|
|
require.Error(s.T(), err, "expected error for cancelled context")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestDownloadFile_InvalidURL() {
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
dest := filepath.Join(s.tempDir, "invalid.bin")
|
|
err := s.client.DownloadFile(context.Background(), "://invalid-url", dest, 100)
|
|
require.Error(s.T(), err, "expected error for invalid URL")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestDownloadFile_InvalidDestPath() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("content"))
|
|
}))
|
|
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
// Use a path that cannot be created (directory doesn't exist)
|
|
dest := filepath.Join(s.tempDir, "nonexistent", "subdir", "file.bin")
|
|
err := s.client.DownloadFile(context.Background(), s.srv.URL, dest, 100)
|
|
require.Error(s.T(), err, "expected error for invalid destination path")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestFetchChecksumFile_InvalidURL() {
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
_, err := s.client.FetchChecksumFile(context.Background(), "://invalid-url")
|
|
require.Error(s.T(), err, "expected error for invalid URL")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Success() {
|
|
releaseJSON := `{
|
|
"tag_name": "v1.0.0",
|
|
"name": "Release 1.0.0",
|
|
"body": "Release notes",
|
|
"html_url": "https://github.com/test/repo/releases/v1.0.0",
|
|
"assets": [
|
|
{
|
|
"name": "app-linux-amd64.tar.gz",
|
|
"browser_download_url": "https://github.com/test/repo/releases/download/v1.0.0/app-linux-amd64.tar.gz"
|
|
}
|
|
]
|
|
}`
|
|
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(s.T(), "/repos/test/repo/releases/latest", r.URL.Path)
|
|
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(releaseJSON))
|
|
}))
|
|
|
|
// Use custom transport to redirect requests to test server
|
|
s.client = &githubReleaseClient{
|
|
httpClient: &http.Client{
|
|
Transport: &testTransport{testServerURL: s.srv.URL},
|
|
},
|
|
downloadHTTPClient: &http.Client{},
|
|
}
|
|
|
|
release, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
|
|
require.NoError(s.T(), err)
|
|
require.Equal(s.T(), "v1.0.0", release.TagName)
|
|
require.Equal(s.T(), "Release 1.0.0", release.Name)
|
|
require.Len(s.T(), release.Assets, 1)
|
|
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)
|
|
}))
|
|
|
|
s.client = &githubReleaseClient{
|
|
httpClient: &http.Client{
|
|
Transport: &testTransport{testServerURL: s.srv.URL},
|
|
},
|
|
downloadHTTPClient: &http.Client{},
|
|
}
|
|
|
|
_, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
|
|
require.Error(s.T(), err)
|
|
require.Contains(s.T(), err.Error(), "404")
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_InvalidJSON() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("not valid json"))
|
|
}))
|
|
|
|
s.client = &githubReleaseClient{
|
|
httpClient: &http.Client{
|
|
Transport: &testTransport{testServerURL: s.srv.URL},
|
|
},
|
|
downloadHTTPClient: &http.Client{},
|
|
}
|
|
|
|
_, err := s.client.FetchLatestRelease(context.Background(), "test/repo")
|
|
require.Error(s.T(), err)
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_ContextCancel() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
}))
|
|
|
|
s.client = &githubReleaseClient{
|
|
httpClient: &http.Client{
|
|
Transport: &testTransport{testServerURL: s.srv.URL},
|
|
},
|
|
downloadHTTPClient: &http.Client{},
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := s.client.FetchLatestRelease(ctx, "test/repo")
|
|
require.Error(s.T(), err)
|
|
}
|
|
|
|
func (s *GitHubReleaseServiceSuite) TestFetchChecksumFile_ContextCancel() {
|
|
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
}))
|
|
|
|
s.client = newTestGitHubReleaseClient()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := s.client.FetchChecksumFile(ctx, s.srv.URL)
|
|
require.Error(s.T(), err)
|
|
}
|
|
|
|
func TestGitHubReleaseServiceSuite(t *testing.T) {
|
|
suite.Run(t, new(GitHubReleaseServiceSuite))
|
|
}
|