| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- package panel
- import (
- "fmt"
- "os"
- "runtime"
- "sync"
- "sync/atomic"
- "testing"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/config"
- "github.com/mhsanaei/3x-ui/v3/internal/web/service"
- )
- func TestIsNewerVersion(t *testing.T) {
- cases := []struct {
- latest string
- current string
- want bool
- }{
- {"v2.9.4", "2.9.3", true},
- {"v2.10.0", "2.9.9", true},
- {"v2.9.3", "2.9.3", false},
- {"v2.9.2", "2.9.3", false},
- {"v3.0.0", "2.9.3", true},
- }
- for _, tc := range cases {
- if got := isNewerVersion(tc.latest, tc.current); got != tc.want {
- t.Fatalf("isNewerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want)
- }
- }
- }
- func TestCompareVersionStringsRejectsUnexpectedFormats(t *testing.T) {
- if _, ok := compareVersionStrings("latest", "2.9.3"); ok {
- t.Fatal("expected non-semver latest tag to be rejected")
- }
- if _, ok := compareVersionStrings("v2.9", "2.9.3"); ok {
- t.Fatal("expected short version to be rejected")
- }
- }
- func TestShellQuote(t *testing.T) {
- if got := shellQuote("/usr/bin/curl"); got != "'/usr/bin/curl'" {
- t.Fatalf("unexpected quote result: %s", got)
- }
- if got := shellQuote("/tmp/a'b"); got != "'/tmp/a'\\''b'" {
- t.Fatalf("unexpected quote result with single quote: %s", got)
- }
- }
- func TestExtractReleaseCommit(t *testing.T) {
- full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
- cases := []struct {
- name string
- release service.Release
- want string
- }{
- {
- name: "from body marker",
- release: service.Release{Body: "Rolling build\n\ncommit=" + full + "\nbuilt=2026-06-24T00:00:00Z"},
- want: full,
- },
- {
- name: "body marker is case-insensitive and wins over target",
- release: service.Release{Body: "COMMIT=" + full, TargetCommitish: "deadbeef"},
- want: full,
- },
- {
- name: "fallback to target commit sha",
- release: service.Release{Body: "no marker here", TargetCommitish: full},
- want: full,
- },
- {
- name: "branch target is not a commit",
- release: service.Release{Body: "no marker", TargetCommitish: "main"},
- want: "",
- },
- }
- for _, tc := range cases {
- if got := extractReleaseCommit(&tc.release); got != tc.want {
- t.Fatalf("%s: extractReleaseCommit = %q, want %q", tc.name, got, tc.want)
- }
- }
- }
- func TestCommitsEqual(t *testing.T) {
- full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
- cases := []struct {
- a, b string
- want bool
- }{
- {"1a2b3c4d", full, true}, // injected 8-char prefix matches full release sha
- {full, "1a2b3c4d", true}, // order independent
- {"1A2B3C4D", full, true}, // case insensitive
- {"deadbeef", full, false}, // different commit
- {"", full, false}, // empty current never matches
- {"1a2b3c4d", "", false}, // empty latest never matches
- }
- for _, tc := range cases {
- if got := commitsEqual(tc.a, tc.b); got != tc.want {
- t.Fatalf("commitsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
- }
- }
- }
- func TestShortCommit(t *testing.T) {
- if got := shortCommit("1a2b3c4d5e6f7a8b"); got != "1a2b3c4d" {
- t.Fatalf("shortCommit truncation = %q, want %q", got, "1a2b3c4d")
- }
- if got := shortCommit("abc"); got != "abc" {
- t.Fatalf("shortCommit short input = %q, want %q", got, "abc")
- }
- }
- func resetUpdateSlot(t *testing.T) {
- t.Helper()
- t.Cleanup(func() {
- updateMu.Lock()
- updateRunning = false
- updateRunID = 0
- updatePID = 0
- updateMu.Unlock()
- })
- }
- // writeStatusFile hand-writes the status file in the exact wire format
- // update.sh itself produces (a bare printf, not Go's json.Marshal), since
- // that's the real cross-language contract this package reads in production.
- func writeStatusFile(t *testing.T, path string, runID int64, state string) {
- t.Helper()
- body := fmt.Sprintf(`{"runId":"%d","state":"%s","exitCode":0,"finishedAt":%d}`, runID, state, time.Now().Unix())
- if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
- t.Fatal(err)
- }
- }
- func TestAcquireUpdateSlot(t *testing.T) {
- resetUpdateSlot(t)
- if !acquireUpdateSlot(1) {
- t.Fatal("first acquire: got false, want true")
- }
- if acquireUpdateSlot(2) {
- t.Fatal("second acquire while first is held: got true, want false")
- }
- releaseUpdateSlot()
- if !acquireUpdateSlot(3) {
- t.Fatal("acquire after release: got false, want true")
- }
- releaseUpdateSlot()
- }
- func TestAcquireUpdateSlotExpiresAfterStaleWindow(t *testing.T) {
- resetUpdateSlot(t)
- if !acquireUpdateSlot(1) {
- t.Fatal("first acquire: got false, want true")
- }
- updateMu.Lock()
- updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
- updateMu.Unlock()
- if !acquireUpdateSlot(2) {
- t.Fatal("acquire after stale window elapsed: got false, want true")
- }
- releaseUpdateSlot()
- }
- // TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow is the regression
- // test for the concurrency bug an upstream review found: past
- // updateStaleAfter, the old logic freed the slot purely on elapsed time, even
- // if the process it launched was still genuinely running (not crashed) --
- // update.sh's own package-manager step plus several downloads can plausibly
- // run long on a slow host with nothing actually wrong. Now a confirmed-alive
- // PID keeps the slot held past the stale window.
- func TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow(t *testing.T) {
- if runtime.GOOS != "linux" {
- t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
- }
- resetUpdateSlot(t)
- if !acquireUpdateSlot(1) {
- t.Fatal("first acquire: got false, want true")
- }
- recordUpdatePID(os.Getpid()) // the test process itself: guaranteed alive
- updateMu.Lock()
- updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
- updateMu.Unlock()
- if acquireUpdateSlot(2) {
- t.Fatal("acquire past the stale window while the recorded PID is still alive: got true, want false")
- }
- releaseUpdateSlot()
- }
- // TestAcquireUpdateSlotHardCeilingOverridesLiveness confirms the absolute
- // backstop: even a confirmed-alive process can't hold the slot forever, so a
- // genuinely wedged run can't lock out retries permanently.
- func TestAcquireUpdateSlotHardCeilingOverridesLiveness(t *testing.T) {
- if runtime.GOOS != "linux" {
- t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
- }
- resetUpdateSlot(t)
- if !acquireUpdateSlot(1) {
- t.Fatal("first acquire: got false, want true")
- }
- recordUpdatePID(os.Getpid())
- updateMu.Lock()
- updateStarted = time.Now().Add(-(updateHardCeiling + time.Second))
- updateMu.Unlock()
- if !acquireUpdateSlot(2) {
- t.Fatal("acquire past the hard ceiling despite a live PID: got false, want true")
- }
- releaseUpdateSlot()
- }
- // TestAcquireUpdateSlotReleasesOnTerminalStatus is the regression test for the
- // bug adversarial review found: a fast failure used to still lock out retries
- // for the full updateStaleAfter window, because acquireUpdateSlot only looked
- // at the in-memory started-at timestamp, never at the status file's own
- // terminal state.
- func TestAcquireUpdateSlotReleasesOnTerminalStatus(t *testing.T) {
- t.Setenv("XUI_DB_FOLDER", t.TempDir())
- resetUpdateSlot(t)
- path := config.GetUpdateStatusFilePath()
- if !acquireUpdateSlot(111) {
- t.Fatal("first acquire: got false, want true")
- }
- writeStatusFile(t, path, 111, updateStateFailed)
- if !acquireUpdateSlot(222) {
- t.Fatal("acquire after the in-flight run reported failed: got false, want true (should not wait out updateStaleAfter)")
- }
- releaseUpdateSlot()
- }
- // TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus confirms the terminal-state
- // check is scoped to the run it actually launched: a status file left behind
- // by some earlier, unrelated run (different runID) must not be mistaken for
- // this run finishing.
- func TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus(t *testing.T) {
- t.Setenv("XUI_DB_FOLDER", t.TempDir())
- resetUpdateSlot(t)
- path := config.GetUpdateStatusFilePath()
- writeStatusFile(t, path, 999, updateStateSuccess)
- if !acquireUpdateSlot(111) {
- t.Fatal("first acquire: got false, want true")
- }
- if acquireUpdateSlot(222) {
- t.Fatal("acquire while status file only reflects an unrelated older runID: got true, want false")
- }
- releaseUpdateSlot()
- }
- // TestAcquireUpdateSlotConcurrency proves the check-then-set is actually
- // atomic under real concurrent access, not just correct when called
- // sequentially. A prior version of this test suite only ever called
- // acquireUpdateSlot from a single goroutine, so it gave no signal if the
- // mutex's core promise (only one concurrent launch wins) were broken.
- func TestAcquireUpdateSlotConcurrency(t *testing.T) {
- resetUpdateSlot(t)
- const attempts = 200
- var wins atomic.Int32
- var wg sync.WaitGroup
- wg.Add(attempts)
- for i := range attempts {
- go func(runID int64) {
- defer wg.Done()
- if acquireUpdateSlot(runID) {
- wins.Add(1)
- }
- }(int64(i))
- }
- wg.Wait()
- if got := wins.Load(); got != 1 {
- t.Fatalf("concurrent acquireUpdateSlot: %d of %d attempts won, want exactly 1", got, attempts)
- }
- releaseUpdateSlot()
- }
- func TestGetUpdateStatus(t *testing.T) {
- t.Setenv("XUI_DB_FOLDER", t.TempDir())
- path := config.GetUpdateStatusFilePath()
- svc := &PanelService{}
- if got := svc.GetUpdateStatus(); got.State != updateStatePending {
- t.Fatalf("missing status file: State = %q, want %q", got.State, updateStatePending)
- }
- writeStatusFile(t, path, 1735689600123456789, updateStateSuccess)
- got := svc.GetUpdateStatus()
- if got.RunID != "1735689600123456789" {
- 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")
- }
- if got.State != updateStateSuccess {
- t.Fatalf("State = %q, want %q", got.State, updateStateSuccess)
- }
- if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil {
- t.Fatal(err)
- }
- if got := svc.GetUpdateStatus(); got.State != updateStatePending {
- t.Fatalf("corrupt status file: State = %q, want %q", got.State, updateStatePending)
- }
- writeStatusFile(t, path, 1, "some-unrecognized-state")
- if got := svc.GetUpdateStatus(); got.State != updateStatePending {
- t.Fatalf("unrecognized state normalizes to pending: State = %q, want %q", got.State, updateStatePending)
- }
- }
|