package agent import ( "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) // ITEM6 B5 switchover tests: the poll-level switch sequence. The // fake xray binary stands in for the legacy core so the flush → // stop → activate → (rollback) ordering can be pinned without a // real core; real-core behavior is covered by the env-gated E2E. const fakeXrayScript = `#!/bin/sh case "$1" in -version|version) echo "Xray 26.6.27 (fake)" exit 0 ;; esac exec sleep 3600 ` func setupFakeXray(t *testing.T, binDir string) { t.Helper() path := filepath.Join(binDir, xray.GetBinaryName()) if err := os.WriteFile(path, []byte(fakeXrayScript), 0o755); err != nil { t.Fatal(err) } } // startLegacyXray puts the agent into the pre-switchover state: a // running xray core whose payload is remembered for rollback. func startLegacyXray(t *testing.T, a *Agent) { t.Helper() cfg := json.RawMessage(`{"inbounds":[],"outbounds":[]}`) if err := a.applyXrayConfig(cfg); err != nil { t.Fatalf("apply legacy xray config: %v", err) } a.lastXrayConfig = append(json.RawMessage(nil), cfg...) if a.xrayProc == nil || !a.xrayProc.IsRunning() { t.Fatal("fake xray core not running") } } func TestSwitchoverRetiresXrayAndRunsSingbox(t *testing.T) { binDir := setupFakeSingbox(t) setupFakeXray(t, binDir) a := newApplyTestAgent() t.Cleanup(func() { a.stopXray(); a.stopSingbox() }) startLegacyXray(t, a) if err := a.switchApplySingbox([]byte(sbCfgA)); err != nil { t.Fatalf("switch apply: %v", err) } if a.xrayProc != nil { t.Fatal("xray core must be retired by the switchover") } if !a.singboxRunning() { t.Fatal("sing-box must be running after the switchover") } if got := appliedConfig(t); got != sbCfgA { t.Fatalf("applied config = %q, want cfgA", got) } } func TestSwitchoverFailureRestartsXray(t *testing.T) { binDir := setupFakeSingbox(t) setupFakeXray(t, binDir) a := newApplyTestAgent() t.Cleanup(func() { a.stopXray(); a.stopSingbox() }) startLegacyXray(t, a) bad := `{"log":{"level":"info"},"inbounds":[{"type":"BADSCHEMA"}]}` if err := a.switchApplySingbox([]byte(bad)); err == nil { t.Fatal("switch to a check-failing config must fail") } if a.xrayProc == nil || !a.xrayProc.IsRunning() { t.Fatal("xray must be restarted from the last payload after a failed switchover") } if a.singboxRunning() { t.Fatal("sing-box must not be running after a failed switchover") } } func TestFetchAndApplySwitchoverIgnoresLegacyAfterSwitch(t *testing.T) { setupFakeSingbox(t) sum := sha256.Sum256([]byte(sbCfgA)) mux := http.NewServeMux() mux.HandleFunc("/panel/api/agent/config", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "singbox_config": json.RawMessage(sbCfgA), "singbox_config_hash": hex.EncodeToString(sum[:]), }) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) a := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n"}) t.Cleanup(a.stopSingbox) if err := a.fetchAndApplyConfig(); err != nil { t.Fatalf("fetch+apply: %v", err) } if !a.sbSwitched { t.Fatal("agent must record the switchover once the master serves sing-box") } if !a.singboxRunning() { t.Fatal("sing-box must be running after the switchover poll") } if a.lastSingboxHash != hex.EncodeToString(sum[:]) { t.Fatalf("lastSingboxHash = %q, want the served hash", a.lastSingboxHash) } } func TestRotateSingboxLogIfOversize(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "singbox.log") small := []byte("one line\n") if err := os.WriteFile(path, small, 0o640); err != nil { t.Fatal(err) } rotateSingboxLogIfOversize(path) if _, err := os.Stat(path + ".1"); !os.IsNotExist(err) { t.Fatal("a small log must not be rotated") } big := make([]byte, singboxLogMaxBytes+123) for i := range big { big[i] = 'x' } if err := os.WriteFile(path, big, 0o640); err != nil { t.Fatal(err) } rotateSingboxLogIfOversize(path) fi, err := os.Stat(path) if err != nil || fi.Size() != 0 { t.Fatalf("live log after rotation: size=%v err=%v, want truncated to 0", fi, err) } prev, err := os.Stat(path + ".1") if err != nil || prev.Size() != int64(len(big)) { t.Fatalf("previous log: size=%v err=%v, want %d", prev, err, len(big)) } }