system_handler_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. //go:build unit
  2. package admin
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. )
  17. type systemHandlerUpdateServiceStub struct {
  18. performErr error
  19. updateInfo *service.UpdateInfo
  20. checkErr error
  21. checkForces []bool
  22. performCall int
  23. performCtxErr error
  24. performHasDeadline bool
  25. rollbackCall int
  26. rollbackToCall int
  27. rollbackToCtxErr error
  28. rollbackToHasDeadline bool
  29. rollbackToVersions []string
  30. rollbackToErr error
  31. rollbackVersions []service.RollbackVersion
  32. rollbackVersionsErr error
  33. rollbackVersionsCall int
  34. }
  35. func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) {
  36. s.checkForces = append(s.checkForces, force)
  37. return s.updateInfo, s.checkErr
  38. }
  39. func (s *systemHandlerUpdateServiceStub) PerformUpdate(ctx context.Context) error {
  40. s.performCall++
  41. s.performCtxErr = ctx.Err()
  42. _, s.performHasDeadline = ctx.Deadline()
  43. return s.performErr
  44. }
  45. func (s *systemHandlerUpdateServiceStub) Rollback() error {
  46. s.rollbackCall++
  47. return nil
  48. }
  49. func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) ([]service.RollbackVersion, error) {
  50. s.rollbackVersionsCall++
  51. return s.rollbackVersions, s.rollbackVersionsErr
  52. }
  53. func (s *systemHandlerUpdateServiceStub) RollbackToVersion(ctx context.Context, version string) error {
  54. s.rollbackToCall++
  55. s.rollbackToCtxErr = ctx.Err()
  56. _, s.rollbackToHasDeadline = ctx.Deadline()
  57. s.rollbackToVersions = append(s.rollbackToVersions, version)
  58. return s.rollbackToErr
  59. }
  60. type systemUpdateResponseEnvelope struct {
  61. Code int `json:"code"`
  62. Message string `json:"message"`
  63. Data struct {
  64. Message string `json:"message"`
  65. AlreadyUpToDate bool `json:"already_up_to_date"`
  66. CurrentVersion string `json:"current_version"`
  67. LatestVersion string `json:"latest_version"`
  68. OperationID string `json:"operation_id"`
  69. } `json:"data"`
  70. }
  71. type systemUpdateErrorEnvelope struct {
  72. Code int `json:"code"`
  73. Message string `json:"message"`
  74. }
  75. func newSystemHandlerTestRouter(t *testing.T, updateSvc *systemHandlerUpdateServiceStub, repo *memoryIdempotencyRepoStub) *gin.Engine {
  76. t.Helper()
  77. gin.SetMode(gin.TestMode)
  78. service.SetDefaultIdempotencyCoordinator(nil)
  79. t.Cleanup(func() {
  80. service.SetDefaultIdempotencyCoordinator(nil)
  81. })
  82. lockSvc := service.NewSystemOperationLockService(repo, service.IdempotencyConfig{
  83. ProcessingTimeout: time.Second,
  84. SystemOperationTTL: time.Minute,
  85. })
  86. handler := NewSystemHandler(updateSvc, lockSvc)
  87. router := gin.New()
  88. router.Use(func(c *gin.Context) { c.Set(string(middleware.ContextKeyUserRole), service.RoleSuperAdmin) })
  89. router.POST("/api/v1/admin/system/update", handler.PerformUpdate)
  90. router.POST("/api/v1/admin/system/rollback", handler.Rollback)
  91. router.GET("/api/v1/admin/system/rollback-versions", handler.GetRollbackVersions)
  92. return router
  93. }
  94. func requireSystemLockStatus(t *testing.T, repo *memoryIdempotencyRepoStub, wantStatus string) {
  95. t.Helper()
  96. repo.mu.Lock()
  97. defer repo.mu.Unlock()
  98. for _, record := range repo.data {
  99. if record.Status == wantStatus {
  100. return
  101. }
  102. }
  103. t.Fatalf("system lock status %q not found in records: %#v", wantStatus, repo.data)
  104. }
  105. func TestSystemHandlerPerformUpdateAlreadyUpToDateReturnsOK(t *testing.T) {
  106. updateSvc := &systemHandlerUpdateServiceStub{
  107. performErr: service.ErrNoUpdateAvailable,
  108. updateInfo: &service.UpdateInfo{
  109. CurrentVersion: "0.1.132",
  110. LatestVersion: "0.1.132",
  111. HasUpdate: false,
  112. },
  113. }
  114. repo := newMemoryIdempotencyRepoStub()
  115. router := newSystemHandlerTestRouter(t, updateSvc, repo)
  116. rec := httptest.NewRecorder()
  117. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
  118. req.Header.Set("Idempotency-Key", "already-up-to-date")
  119. router.ServeHTTP(rec, req)
  120. require.Equal(t, http.StatusOK, rec.Code)
  121. require.Equal(t, 1, updateSvc.performCall)
  122. require.Equal(t, []bool{false}, updateSvc.checkForces)
  123. requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
  124. var body systemUpdateResponseEnvelope
  125. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
  126. require.Equal(t, 0, body.Code)
  127. require.Equal(t, "success", body.Message)
  128. require.Equal(t, "Already up to date", body.Data.Message)
  129. require.True(t, body.Data.AlreadyUpToDate)
  130. require.Equal(t, "0.1.132", body.Data.CurrentVersion)
  131. require.Equal(t, "0.1.132", body.Data.LatestVersion)
  132. require.NotEmpty(t, body.Data.OperationID)
  133. }
  134. func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T) {
  135. updateSvc := &systemHandlerUpdateServiceStub{
  136. performErr: errors.New("download failed"),
  137. }
  138. repo := newMemoryIdempotencyRepoStub()
  139. router := newSystemHandlerTestRouter(t, updateSvc, repo)
  140. rec := httptest.NewRecorder()
  141. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
  142. req.Header.Set("Idempotency-Key", "real-failure")
  143. router.ServeHTTP(rec, req)
  144. require.Equal(t, http.StatusInternalServerError, rec.Code)
  145. require.Equal(t, 1, updateSvc.performCall)
  146. require.Empty(t, updateSvc.checkForces)
  147. requireSystemLockStatus(t, repo, service.IdempotencyStatusFailedRetryable)
  148. var body systemUpdateErrorEnvelope
  149. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
  150. require.Equal(t, http.StatusInternalServerError, body.Code)
  151. require.Equal(t, "internal error", body.Message)
  152. }
  153. // TestSystemHandlerPerformUpdateSurvivesClientDisconnect reproduces #4504:
  154. // the browser or a reverse proxy (axios 30s default, nginx proxy_read_timeout
  155. // 60s) aborts the long-running update request and cancels the request
  156. // context. The download must keep running on a detached, bounded context
  157. // instead of dying with "download failed: context canceled".
  158. func TestSystemHandlerPerformUpdateSurvivesClientDisconnect(t *testing.T) {
  159. updateSvc := &systemHandlerUpdateServiceStub{}
  160. repo := newMemoryIdempotencyRepoStub()
  161. router := newSystemHandlerTestRouter(t, updateSvc, repo)
  162. rec := httptest.NewRecorder()
  163. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil)
  164. canceledCtx, cancel := context.WithCancel(context.Background())
  165. cancel()
  166. req = req.WithContext(canceledCtx)
  167. req.Header.Set("Idempotency-Key", "disconnected-update")
  168. router.ServeHTTP(rec, req)
  169. require.Equal(t, 1, updateSvc.performCall)
  170. require.NoError(t, updateSvc.performCtxErr,
  171. "update must not observe the canceled request context")
  172. require.True(t, updateSvc.performHasDeadline,
  173. "detached update context must still be bounded by a deadline")
  174. requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
  175. }
  176. func TestSystemHandlerRollbackToVersionSurvivesClientDisconnect(t *testing.T) {
  177. updateSvc := &systemHandlerUpdateServiceStub{}
  178. repo := newMemoryIdempotencyRepoStub()
  179. router := newSystemHandlerTestRouter(t, updateSvc, repo)
  180. rec := httptest.NewRecorder()
  181. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
  182. strings.NewReader(`{"version":"0.1.146"}`))
  183. req.Header.Set("Content-Type", "application/json")
  184. canceledCtx, cancel := context.WithCancel(context.Background())
  185. cancel()
  186. req = req.WithContext(canceledCtx)
  187. req.Header.Set("Idempotency-Key", "disconnected-rollback")
  188. router.ServeHTTP(rec, req)
  189. require.Equal(t, 1, updateSvc.rollbackToCall)
  190. require.NoError(t, updateSvc.rollbackToCtxErr,
  191. "versioned rollback must not observe the canceled request context")
  192. require.True(t, updateSvc.rollbackToHasDeadline,
  193. "detached rollback context must still be bounded by a deadline")
  194. requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
  195. }
  196. func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup(t *testing.T) {
  197. updateSvc := &systemHandlerUpdateServiceStub{}
  198. repo := newMemoryIdempotencyRepoStub()
  199. router := newSystemHandlerTestRouter(t, updateSvc, repo)
  200. rec := httptest.NewRecorder()
  201. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback", nil)
  202. req.Header.Set("Idempotency-Key", "legacy-rollback")
  203. router.ServeHTTP(rec, req)
  204. require.Equal(t, http.StatusOK, rec.Code)
  205. require.Equal(t, 1, updateSvc.rollbackCall)
  206. require.Equal(t, 0, updateSvc.rollbackToCall)
  207. requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
  208. }
  209. func TestSystemHandlerRollbackWithVersionCallsRollbackToVersion(t *testing.T) {
  210. updateSvc := &systemHandlerUpdateServiceStub{}
  211. repo := newMemoryIdempotencyRepoStub()
  212. router := newSystemHandlerTestRouter(t, updateSvc, repo)
  213. rec := httptest.NewRecorder()
  214. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
  215. strings.NewReader(`{"version":"0.1.146"}`))
  216. req.Header.Set("Content-Type", "application/json")
  217. req.Header.Set("Idempotency-Key", "rollback-to-146")
  218. router.ServeHTTP(rec, req)
  219. require.Equal(t, http.StatusOK, rec.Code)
  220. require.Equal(t, 0, updateSvc.rollbackCall)
  221. require.Equal(t, 1, updateSvc.rollbackToCall)
  222. require.Equal(t, []string{"0.1.146"}, updateSvc.rollbackToVersions)
  223. requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
  224. var body systemUpdateResponseEnvelope
  225. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
  226. require.Equal(t, 0, body.Code)
  227. require.Equal(t, "Rollback completed. Please restart the service.", body.Data.Message)
  228. }
  229. func TestSystemHandlerRollbackWithDisallowedVersionReturnsBadRequest(t *testing.T) {
  230. updateSvc := &systemHandlerUpdateServiceStub{
  231. rollbackToErr: service.ErrRollbackVersionNotAllowed,
  232. }
  233. repo := newMemoryIdempotencyRepoStub()
  234. router := newSystemHandlerTestRouter(t, updateSvc, repo)
  235. rec := httptest.NewRecorder()
  236. req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
  237. strings.NewReader(`{"version":"9.9.9"}`))
  238. req.Header.Set("Content-Type", "application/json")
  239. req.Header.Set("Idempotency-Key", "rollback-to-bad")
  240. router.ServeHTTP(rec, req)
  241. require.Equal(t, http.StatusBadRequest, rec.Code)
  242. require.Equal(t, 1, updateSvc.rollbackToCall)
  243. }
  244. func TestSystemHandlerGetRollbackVersions(t *testing.T) {
  245. updateSvc := &systemHandlerUpdateServiceStub{
  246. rollbackVersions: []service.RollbackVersion{
  247. {Version: "0.1.146", PublishedAt: "2026-07-07T00:00:00Z", HTMLURL: "https://example.com/v0.1.146"},
  248. {Version: "0.1.145", PublishedAt: "2026-07-06T00:00:00Z", HTMLURL: "https://example.com/v0.1.145"},
  249. },
  250. }
  251. repo := newMemoryIdempotencyRepoStub()
  252. router := newSystemHandlerTestRouter(t, updateSvc, repo)
  253. rec := httptest.NewRecorder()
  254. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil)
  255. router.ServeHTTP(rec, req)
  256. require.Equal(t, http.StatusOK, rec.Code)
  257. require.Equal(t, 1, updateSvc.rollbackVersionsCall)
  258. var body struct {
  259. Code int `json:"code"`
  260. Data struct {
  261. Versions []service.RollbackVersion `json:"versions"`
  262. } `json:"data"`
  263. }
  264. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
  265. require.Equal(t, 0, body.Code)
  266. require.Len(t, body.Data.Versions, 2)
  267. require.Equal(t, "0.1.146", body.Data.Versions[0].Version)
  268. }
  269. func TestSystemHandlerGetRollbackVersionsError(t *testing.T) {
  270. updateSvc := &systemHandlerUpdateServiceStub{
  271. rollbackVersionsErr: errors.New("github unavailable"),
  272. }
  273. repo := newMemoryIdempotencyRepoStub()
  274. router := newSystemHandlerTestRouter(t, updateSvc, repo)
  275. rec := httptest.NewRecorder()
  276. req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil)
  277. router.ServeHTTP(rec, req)
  278. require.Equal(t, http.StatusInternalServerError, rec.Code)
  279. }