| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- //go:build unit
- package admin
- import (
- "context"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type systemHandlerUpdateServiceStub struct {
- performErr error
- updateInfo *service.UpdateInfo
- checkErr error
- checkForces []bool
- performCall int
- performCtxErr error
- performHasDeadline bool
- rollbackCall int
- rollbackToCall int
- rollbackToCtxErr error
- rollbackToHasDeadline bool
- rollbackToVersions []string
- rollbackToErr error
- rollbackVersions []service.RollbackVersion
- rollbackVersionsErr error
- rollbackVersionsCall int
- }
- func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) {
- s.checkForces = append(s.checkForces, force)
- return s.updateInfo, s.checkErr
- }
- func (s *systemHandlerUpdateServiceStub) PerformUpdate(ctx context.Context) error {
- s.performCall++
- s.performCtxErr = ctx.Err()
- _, s.performHasDeadline = ctx.Deadline()
- return s.performErr
- }
- 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(ctx context.Context, version string) error {
- s.rollbackToCall++
- s.rollbackToCtxErr = ctx.Err()
- _, s.rollbackToHasDeadline = ctx.Deadline()
- s.rollbackToVersions = append(s.rollbackToVersions, version)
- return s.rollbackToErr
- }
- type systemUpdateResponseEnvelope struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Data struct {
- Message string `json:"message"`
- AlreadyUpToDate bool `json:"already_up_to_date"`
- CurrentVersion string `json:"current_version"`
- LatestVersion string `json:"latest_version"`
- OperationID string `json:"operation_id"`
- } `json:"data"`
- }
- type systemUpdateErrorEnvelope struct {
- Code int `json:"code"`
- Message string `json:"message"`
- }
- func newSystemHandlerTestRouter(t *testing.T, updateSvc *systemHandlerUpdateServiceStub, repo *memoryIdempotencyRepoStub) *gin.Engine {
- t.Helper()
- gin.SetMode(gin.TestMode)
- service.SetDefaultIdempotencyCoordinator(nil)
- t.Cleanup(func() {
- service.SetDefaultIdempotencyCoordinator(nil)
- })
- lockSvc := service.NewSystemOperationLockService(repo, service.IdempotencyConfig{
- ProcessingTimeout: time.Second,
- SystemOperationTTL: time.Minute,
- })
- handler := NewSystemHandler(updateSvc, lockSvc)
- router := gin.New()
- router.Use(func(c *gin.Context) { c.Set(string(middleware.ContextKeyUserRole), service.RoleSuperAdmin) })
- 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
- }
- func requireSystemLockStatus(t *testing.T, repo *memoryIdempotencyRepoStub, wantStatus string) {
- t.Helper()
- repo.mu.Lock()
- defer repo.mu.Unlock()
- for _, record := range repo.data {
- if record.Status == wantStatus {
- return
- }
- }
- t.Fatalf("system lock status %q not found in records: %#v", wantStatus, repo.data)
- }
- func TestSystemHandlerPerformUpdateAlreadyUpToDateReturnsOK(t *testing.T) {
- updateSvc := &systemHandlerUpdateServiceStub{
- performErr: service.ErrNoUpdateAvailable,
- updateInfo: &service.UpdateInfo{
- CurrentVersion: "0.1.132",
- LatestVersion: "0.1.132",
- HasUpdate: false,
- },
- }
- repo := newMemoryIdempotencyRepoStub()
- router := newSystemHandlerTestRouter(t, updateSvc, repo)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
- req.Header.Set("Idempotency-Key", "already-up-to-date")
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- require.Equal(t, 1, updateSvc.performCall)
- require.Equal(t, []bool{false}, updateSvc.checkForces)
- 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, "success", body.Message)
- require.Equal(t, "Already up to date", body.Data.Message)
- require.True(t, body.Data.AlreadyUpToDate)
- require.Equal(t, "0.1.132", body.Data.CurrentVersion)
- require.Equal(t, "0.1.132", body.Data.LatestVersion)
- require.NotEmpty(t, body.Data.OperationID)
- }
- func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T) {
- updateSvc := &systemHandlerUpdateServiceStub{
- performErr: errors.New("download failed"),
- }
- repo := newMemoryIdempotencyRepoStub()
- router := newSystemHandlerTestRouter(t, updateSvc, repo)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
- req.Header.Set("Idempotency-Key", "real-failure")
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusInternalServerError, rec.Code)
- require.Equal(t, 1, updateSvc.performCall)
- require.Empty(t, updateSvc.checkForces)
- requireSystemLockStatus(t, repo, service.IdempotencyStatusFailedRetryable)
- var body systemUpdateErrorEnvelope
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
- require.Equal(t, http.StatusInternalServerError, body.Code)
- require.Equal(t, "internal error", body.Message)
- }
- // TestSystemHandlerPerformUpdateSurvivesClientDisconnect reproduces #4504:
- // the browser or a reverse proxy (axios 30s default, nginx proxy_read_timeout
- // 60s) aborts the long-running update request and cancels the request
- // context. The download must keep running on a detached, bounded context
- // instead of dying with "download failed: context canceled".
- func TestSystemHandlerPerformUpdateSurvivesClientDisconnect(t *testing.T) {
- updateSvc := &systemHandlerUpdateServiceStub{}
- repo := newMemoryIdempotencyRepoStub()
- router := newSystemHandlerTestRouter(t, updateSvc, repo)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
- canceledCtx, cancel := context.WithCancel(context.Background())
- cancel()
- req = req.WithContext(canceledCtx)
- req.Header.Set("Idempotency-Key", "disconnected-update")
- router.ServeHTTP(rec, req)
- require.Equal(t, 1, updateSvc.performCall)
- require.NoError(t, updateSvc.performCtxErr,
- "update must not observe the canceled request context")
- require.True(t, updateSvc.performHasDeadline,
- "detached update context must still be bounded by a deadline")
- requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
- }
- func TestSystemHandlerRollbackToVersionSurvivesClientDisconnect(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")
- canceledCtx, cancel := context.WithCancel(context.Background())
- cancel()
- req = req.WithContext(canceledCtx)
- req.Header.Set("Idempotency-Key", "disconnected-rollback")
- router.ServeHTTP(rec, req)
- require.Equal(t, 1, updateSvc.rollbackToCall)
- require.NoError(t, updateSvc.rollbackToCtxErr,
- "versioned rollback must not observe the canceled request context")
- require.True(t, updateSvc.rollbackToHasDeadline,
- "detached rollback context must still be bounded by a deadline")
- requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
- }
- 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)
- }
|