panel_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. package panel
  2. import (
  3. "fmt"
  4. "os"
  5. "runtime"
  6. "sync"
  7. "sync/atomic"
  8. "testing"
  9. "time"
  10. "github.com/mhsanaei/3x-ui/v3/internal/config"
  11. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  12. )
  13. func TestIsNewerVersion(t *testing.T) {
  14. cases := []struct {
  15. latest string
  16. current string
  17. want bool
  18. }{
  19. {"v2.9.4", "2.9.3", true},
  20. {"v2.10.0", "2.9.9", true},
  21. {"v2.9.3", "2.9.3", false},
  22. {"v2.9.2", "2.9.3", false},
  23. {"v3.0.0", "2.9.3", true},
  24. }
  25. for _, tc := range cases {
  26. if got := isNewerVersion(tc.latest, tc.current); got != tc.want {
  27. t.Fatalf("isNewerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want)
  28. }
  29. }
  30. }
  31. func TestCompareVersionStringsRejectsUnexpectedFormats(t *testing.T) {
  32. if _, ok := compareVersionStrings("latest", "2.9.3"); ok {
  33. t.Fatal("expected non-semver latest tag to be rejected")
  34. }
  35. if _, ok := compareVersionStrings("v2.9", "2.9.3"); ok {
  36. t.Fatal("expected short version to be rejected")
  37. }
  38. }
  39. func TestShellQuote(t *testing.T) {
  40. if got := shellQuote("/usr/bin/curl"); got != "'/usr/bin/curl'" {
  41. t.Fatalf("unexpected quote result: %s", got)
  42. }
  43. if got := shellQuote("/tmp/a'b"); got != "'/tmp/a'\\''b'" {
  44. t.Fatalf("unexpected quote result with single quote: %s", got)
  45. }
  46. }
  47. // TestUpdateProxyEnvVars covers the bug this function fixes: ambient proxy
  48. // vars must reach update.sh's systemd-run child, which inherits nothing.
  49. func TestUpdateProxyEnvVars(t *testing.T) {
  50. if runtime.GOOS == "windows" {
  51. t.Skip("Windows env var names are case-insensitive, so both spellings resolve; the updater runs only on Linux")
  52. }
  53. allKeys := []string{"https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY", "http_proxy", "HTTP_PROXY", "no_proxy", "NO_PROXY"}
  54. clearAll := func(t *testing.T) {
  55. t.Helper()
  56. for _, key := range allKeys {
  57. t.Setenv(key, "")
  58. }
  59. }
  60. t.Run("nothing set returns nil", func(t *testing.T) {
  61. clearAll(t)
  62. if got := updateProxyEnvVars(); got != nil {
  63. t.Fatalf("updateProxyEnvVars() = %v, want nil", got)
  64. }
  65. })
  66. t.Run("forwards each set var under its own name", func(t *testing.T) {
  67. clearAll(t)
  68. t.Setenv("https_proxy", "socks5://127.0.0.1:10808")
  69. t.Setenv("no_proxy", "10.0.0.0/8,localhost")
  70. got := updateProxyEnvVars()
  71. want := []string{"https_proxy=socks5://127.0.0.1:10808", "no_proxy=10.0.0.0/8,localhost"}
  72. if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
  73. t.Fatalf("updateProxyEnvVars() = %v, want %v", got, want)
  74. }
  75. })
  76. // A deliberately HTTP-only proxy config must not silently gain HTTPS traffic.
  77. t.Run("http_proxy is not promoted to https_proxy", func(t *testing.T) {
  78. clearAll(t)
  79. t.Setenv("http_proxy", "http://127.0.0.1:8080")
  80. got := updateProxyEnvVars()
  81. want := []string{"http_proxy=http://127.0.0.1:8080"}
  82. if len(got) != len(want) || got[0] != want[0] {
  83. t.Fatalf("updateProxyEnvVars() = %v, want %v", got, want)
  84. }
  85. })
  86. }
  87. func TestExtractReleaseCommit(t *testing.T) {
  88. full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
  89. cases := []struct {
  90. name string
  91. release service.Release
  92. want string
  93. }{
  94. {
  95. name: "from body marker",
  96. release: service.Release{Body: "Rolling build\n\ncommit=" + full + "\nbuilt=2026-06-24T00:00:00Z"},
  97. want: full,
  98. },
  99. {
  100. name: "body marker is case-insensitive and wins over target",
  101. release: service.Release{Body: "COMMIT=" + full, TargetCommitish: "deadbeef"},
  102. want: full,
  103. },
  104. {
  105. name: "fallback to target commit sha",
  106. release: service.Release{Body: "no marker here", TargetCommitish: full},
  107. want: full,
  108. },
  109. {
  110. name: "branch target is not a commit",
  111. release: service.Release{Body: "no marker", TargetCommitish: "main"},
  112. want: "",
  113. },
  114. }
  115. for _, tc := range cases {
  116. if got := extractReleaseCommit(&tc.release); got != tc.want {
  117. t.Fatalf("%s: extractReleaseCommit = %q, want %q", tc.name, got, tc.want)
  118. }
  119. }
  120. }
  121. func TestCommitsEqual(t *testing.T) {
  122. full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
  123. cases := []struct {
  124. a, b string
  125. want bool
  126. }{
  127. {"1a2b3c4d", full, true}, // injected 8-char prefix matches full release sha
  128. {full, "1a2b3c4d", true}, // order independent
  129. {"1A2B3C4D", full, true}, // case insensitive
  130. {"deadbeef", full, false}, // different commit
  131. {"", full, false}, // empty current never matches
  132. {"1a2b3c4d", "", false}, // empty latest never matches
  133. }
  134. for _, tc := range cases {
  135. if got := commitsEqual(tc.a, tc.b); got != tc.want {
  136. t.Fatalf("commitsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
  137. }
  138. }
  139. }
  140. func TestShortCommit(t *testing.T) {
  141. if got := shortCommit("1a2b3c4d5e6f7a8b"); got != "1a2b3c4d" {
  142. t.Fatalf("shortCommit truncation = %q, want %q", got, "1a2b3c4d")
  143. }
  144. if got := shortCommit("abc"); got != "abc" {
  145. t.Fatalf("shortCommit short input = %q, want %q", got, "abc")
  146. }
  147. }
  148. func resetUpdateSlot(t *testing.T) {
  149. t.Helper()
  150. t.Cleanup(func() {
  151. updateMu.Lock()
  152. updateRunning = false
  153. updateRunID = 0
  154. updatePID = 0
  155. updateMu.Unlock()
  156. })
  157. }
  158. // writeStatusFile hand-writes the status file in the exact wire format
  159. // update.sh itself produces (a bare printf, not Go's json.Marshal), since
  160. // that's the real cross-language contract this package reads in production.
  161. func writeStatusFile(t *testing.T, path string, runID int64, state string) {
  162. t.Helper()
  163. body := fmt.Sprintf(`{"runId":"%d","state":"%s","exitCode":0,"finishedAt":%d}`, runID, state, time.Now().Unix())
  164. if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
  165. t.Fatal(err)
  166. }
  167. }
  168. func TestAcquireUpdateSlot(t *testing.T) {
  169. resetUpdateSlot(t)
  170. if !acquireUpdateSlot(1) {
  171. t.Fatal("first acquire: got false, want true")
  172. }
  173. if acquireUpdateSlot(2) {
  174. t.Fatal("second acquire while first is held: got true, want false")
  175. }
  176. releaseUpdateSlot()
  177. if !acquireUpdateSlot(3) {
  178. t.Fatal("acquire after release: got false, want true")
  179. }
  180. releaseUpdateSlot()
  181. }
  182. func TestAcquireUpdateSlotExpiresAfterStaleWindow(t *testing.T) {
  183. resetUpdateSlot(t)
  184. if !acquireUpdateSlot(1) {
  185. t.Fatal("first acquire: got false, want true")
  186. }
  187. updateMu.Lock()
  188. updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
  189. updateMu.Unlock()
  190. if !acquireUpdateSlot(2) {
  191. t.Fatal("acquire after stale window elapsed: got false, want true")
  192. }
  193. releaseUpdateSlot()
  194. }
  195. // TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow is the regression
  196. // test for the concurrency bug an upstream review found: past
  197. // updateStaleAfter, the old logic freed the slot purely on elapsed time, even
  198. // if the process it launched was still genuinely running (not crashed) --
  199. // update.sh's own package-manager step plus several downloads can plausibly
  200. // run long on a slow host with nothing actually wrong. Now a confirmed-alive
  201. // PID keeps the slot held past the stale window.
  202. func TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow(t *testing.T) {
  203. if runtime.GOOS != "linux" {
  204. t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
  205. }
  206. resetUpdateSlot(t)
  207. if !acquireUpdateSlot(1) {
  208. t.Fatal("first acquire: got false, want true")
  209. }
  210. recordUpdatePID(os.Getpid()) // the test process itself: guaranteed alive
  211. updateMu.Lock()
  212. updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
  213. updateMu.Unlock()
  214. if acquireUpdateSlot(2) {
  215. t.Fatal("acquire past the stale window while the recorded PID is still alive: got true, want false")
  216. }
  217. releaseUpdateSlot()
  218. }
  219. // TestAcquireUpdateSlotHardCeilingOverridesLiveness confirms the absolute
  220. // backstop: even a confirmed-alive process can't hold the slot forever, so a
  221. // genuinely wedged run can't lock out retries permanently.
  222. func TestAcquireUpdateSlotHardCeilingOverridesLiveness(t *testing.T) {
  223. if runtime.GOOS != "linux" {
  224. t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
  225. }
  226. resetUpdateSlot(t)
  227. if !acquireUpdateSlot(1) {
  228. t.Fatal("first acquire: got false, want true")
  229. }
  230. recordUpdatePID(os.Getpid())
  231. updateMu.Lock()
  232. updateStarted = time.Now().Add(-(updateHardCeiling + time.Second))
  233. updateMu.Unlock()
  234. if !acquireUpdateSlot(2) {
  235. t.Fatal("acquire past the hard ceiling despite a live PID: got false, want true")
  236. }
  237. releaseUpdateSlot()
  238. }
  239. // TestAcquireUpdateSlotReleasesOnTerminalStatus is the regression test for the
  240. // bug adversarial review found: a fast failure used to still lock out retries
  241. // for the full updateStaleAfter window, because acquireUpdateSlot only looked
  242. // at the in-memory started-at timestamp, never at the status file's own
  243. // terminal state.
  244. func TestAcquireUpdateSlotReleasesOnTerminalStatus(t *testing.T) {
  245. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  246. resetUpdateSlot(t)
  247. path := config.GetUpdateStatusFilePath()
  248. if !acquireUpdateSlot(111) {
  249. t.Fatal("first acquire: got false, want true")
  250. }
  251. writeStatusFile(t, path, 111, updateStateFailed)
  252. if !acquireUpdateSlot(222) {
  253. t.Fatal("acquire after the in-flight run reported failed: got false, want true (should not wait out updateStaleAfter)")
  254. }
  255. releaseUpdateSlot()
  256. }
  257. // TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus confirms the terminal-state
  258. // check is scoped to the run it actually launched: a status file left behind
  259. // by some earlier, unrelated run (different runID) must not be mistaken for
  260. // this run finishing.
  261. func TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus(t *testing.T) {
  262. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  263. resetUpdateSlot(t)
  264. path := config.GetUpdateStatusFilePath()
  265. writeStatusFile(t, path, 999, updateStateSuccess)
  266. if !acquireUpdateSlot(111) {
  267. t.Fatal("first acquire: got false, want true")
  268. }
  269. if acquireUpdateSlot(222) {
  270. t.Fatal("acquire while status file only reflects an unrelated older runID: got true, want false")
  271. }
  272. releaseUpdateSlot()
  273. }
  274. // TestAcquireUpdateSlotConcurrency proves the check-then-set is actually
  275. // atomic under real concurrent access, not just correct when called
  276. // sequentially. A prior version of this test suite only ever called
  277. // acquireUpdateSlot from a single goroutine, so it gave no signal if the
  278. // mutex's core promise (only one concurrent launch wins) were broken.
  279. func TestAcquireUpdateSlotConcurrency(t *testing.T) {
  280. resetUpdateSlot(t)
  281. const attempts = 200
  282. var wins atomic.Int32
  283. var wg sync.WaitGroup
  284. wg.Add(attempts)
  285. for i := range attempts {
  286. go func(runID int64) {
  287. defer wg.Done()
  288. if acquireUpdateSlot(runID) {
  289. wins.Add(1)
  290. }
  291. }(int64(i))
  292. }
  293. wg.Wait()
  294. if got := wins.Load(); got != 1 {
  295. t.Fatalf("concurrent acquireUpdateSlot: %d of %d attempts won, want exactly 1", got, attempts)
  296. }
  297. releaseUpdateSlot()
  298. }
  299. func TestGetUpdateStatus(t *testing.T) {
  300. t.Setenv("XUI_DB_FOLDER", t.TempDir())
  301. path := config.GetUpdateStatusFilePath()
  302. svc := &PanelService{}
  303. if got := svc.GetUpdateStatus(); got.State != updateStatePending {
  304. t.Fatalf("missing status file: State = %q, want %q", got.State, updateStatePending)
  305. }
  306. writeStatusFile(t, path, 1735689600123456789, updateStateSuccess)
  307. got := svc.GetUpdateStatus()
  308. if got.RunID != "1735689600123456789" {
  309. t.Fatalf("RunID = %q, want %q (must round-trip as a decimal string, not a JSON number, or it loses precision past 2^53 in JS)", got.RunID, "1735689600123456789")
  310. }
  311. if got.State != updateStateSuccess {
  312. t.Fatalf("State = %q, want %q", got.State, updateStateSuccess)
  313. }
  314. if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil {
  315. t.Fatal(err)
  316. }
  317. if got := svc.GetUpdateStatus(); got.State != updateStatePending {
  318. t.Fatalf("corrupt status file: State = %q, want %q", got.State, updateStatePending)
  319. }
  320. writeStatusFile(t, path, 1, "some-unrecognized-state")
  321. if got := svc.GetUpdateStatus(); got.State != updateStatePending {
  322. t.Fatalf("unrecognized state normalizes to pending: State = %q, want %q", got.State, updateStatePending)
  323. }
  324. }