panel_update_test.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. package controller
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "net/http/httptest"
  6. "net/url"
  7. "os"
  8. "runtime"
  9. "strings"
  10. "testing"
  11. "github.com/mhsanaei/3x-ui/v3/internal/config"
  12. "github.com/gin-gonic/gin"
  13. )
  14. // newPanelUpdateTestEngine registers only updatePanel/getUpdateStatus directly
  15. // on the controller's zero value, bypassing NewServerController's cron/metrics
  16. // setup (unrelated to these two handlers, and unnecessary weight for a unit
  17. // test). Callers must set up a DB first (newHostTestDB(t)) since StartUpdate
  18. // reads the dev-channel setting before doing anything else.
  19. func newPanelUpdateTestEngine() *gin.Engine {
  20. a := &ServerController{}
  21. engine := gin.New()
  22. engine.GET("/panel/api/server/getUpdateStatus", a.getUpdateStatus)
  23. engine.POST("/panel/api/server/updatePanel", a.updatePanel)
  24. return engine
  25. }
  26. func doPanelUpdateReq(t *testing.T, engine *gin.Engine, method, path string) hostEnvelope {
  27. t.Helper()
  28. req := httptest.NewRequest(method, path, nil)
  29. w := httptest.NewRecorder()
  30. engine.ServeHTTP(w, req)
  31. if w.Code != http.StatusOK {
  32. t.Fatalf("%s %s: status %d, body=%s", method, path, w.Code, w.Body.String())
  33. }
  34. var env hostEnvelope
  35. if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
  36. t.Fatalf("%s %s: decode envelope: %v body=%s", method, path, err, w.Body.String())
  37. }
  38. return env
  39. }
  40. // TestGetUpdateStatus_NoStatusFileYet exercises the read-only status endpoint
  41. // with no prior update having run: it must report "pending" (not an error),
  42. // since a missing status file is an expected, ordinary state, not a failure.
  43. func TestGetUpdateStatus_NoStatusFileYet(t *testing.T) {
  44. newHostTestDB(t)
  45. engine := newPanelUpdateTestEngine()
  46. env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus")
  47. if !env.Success {
  48. t.Fatalf("getUpdateStatus should always report success=true (it's a best-effort read): msg=%s", env.Msg)
  49. }
  50. var status struct {
  51. RunID string `json:"runId"`
  52. State string `json:"state"`
  53. }
  54. if err := json.Unmarshal(env.Obj, &status); err != nil {
  55. t.Fatalf("decode status: %v", err)
  56. }
  57. if status.State != "pending" {
  58. t.Fatalf("State = %q, want %q", status.State, "pending")
  59. }
  60. }
  61. // TestGetUpdateStatus_RunIdIsAlwaysAString is the regression test for the
  62. // precision bug found in review: RunID is a 19-digit UnixNano timestamp, so
  63. // it must round-trip over the wire as a JSON string, never a bare number -- a
  64. // bare number would silently lose precision in JavaScript past
  65. // Number.MAX_SAFE_INTEGER, breaking every future runId comparison on the
  66. // frontend. Decoding into a Go string field below only succeeds if the wire
  67. // value is actually a JSON string; a bare number there would fail to decode,
  68. // so this test doubles as the wire-format check.
  69. func TestGetUpdateStatus_RunIdIsAlwaysAString(t *testing.T) {
  70. newHostTestDB(t)
  71. engine := newPanelUpdateTestEngine()
  72. statusPath := config.GetUpdateStatusFilePath()
  73. body := `{"runId":"1735689600123456789","state":"success","exitCode":0,"finishedAt":1735689612}`
  74. if err := os.WriteFile(statusPath, []byte(body), 0o644); err != nil {
  75. t.Fatal(err)
  76. }
  77. env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus")
  78. var status struct {
  79. RunID string `json:"runId"`
  80. State string `json:"state"`
  81. }
  82. if err := json.Unmarshal(env.Obj, &status); err != nil {
  83. t.Fatalf("decode status (would fail here if runId were a bare JSON number instead of a string): %v, body=%s", err, env.Obj)
  84. }
  85. if status.RunID != "1735689600123456789" {
  86. t.Fatalf("RunID = %q, want %q", status.RunID, "1735689600123456789")
  87. }
  88. if status.State != "success" {
  89. t.Fatalf("State = %q, want %q", status.State, "success")
  90. }
  91. }
  92. // TestUpdatePanel_UnsupportedPlatformReturnsNoRunId covers the one path of
  93. // updatePanel that's safe to exercise in an automated test on any OS/CI
  94. // runner: the runtime.GOOS != "linux" guard. Actually invoking StartUpdate's
  95. // launch logic on Linux would make a real network call and could launch a
  96. // real update.sh process, so that path is deliberately not covered here --
  97. // see the PR description for why.
  98. func TestUpdatePanel_UnsupportedPlatformReturnsNoRunId(t *testing.T) {
  99. if runtime.GOOS == "linux" {
  100. t.Skip("this test only exercises the non-Linux guard path; on Linux, updatePanel would attempt a real download/exec")
  101. }
  102. newHostTestDB(t)
  103. engine := newPanelUpdateTestEngine()
  104. env := doPanelUpdateReq(t, engine, http.MethodPost, "/panel/api/server/updatePanel")
  105. if env.Success {
  106. t.Fatal("updatePanel on an unsupported platform: success = true, want false")
  107. }
  108. if len(env.Obj) != 0 && string(env.Obj) != "null" {
  109. t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj)
  110. }
  111. }
  112. // TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch covers the one branch of
  113. // updatePanel that's both untested and safe to exercise on any OS/CI runner:
  114. // an unparseable "dev" form value is rejected by strconv.ParseBool before
  115. // StartUpdateChannel (and therefore any real exec/network call) is ever
  116. // reached, on Linux or otherwise.
  117. func TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch(t *testing.T) {
  118. newHostTestDB(t)
  119. engine := newPanelUpdateTestEngine()
  120. form := url.Values{"dev": {"notabool"}}
  121. req := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", strings.NewReader(form.Encode()))
  122. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  123. w := httptest.NewRecorder()
  124. engine.ServeHTTP(w, req)
  125. if w.Code != http.StatusOK {
  126. t.Fatalf("status %d, body=%s", w.Code, w.Body.String())
  127. }
  128. var env hostEnvelope
  129. if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
  130. t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
  131. }
  132. if env.Success {
  133. t.Fatal("updatePanel with dev=notabool: success = true, want false")
  134. }
  135. if len(env.Obj) != 0 && string(env.Obj) != "null" {
  136. t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj)
  137. }
  138. }