mutation_audit_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. package xray
  2. import (
  3. "os"
  4. "os/exec"
  5. "path/filepath"
  6. "slices"
  7. "strings"
  8. "testing"
  9. "time"
  10. "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
  11. )
  12. // ---------------------------------------------------------------------------
  13. // hot_diff.go mutation audits
  14. // ---------------------------------------------------------------------------
  15. // TestDiffOutbounds_EmptyOutboundsNoPanic pins hot_diff.go:154 — the
  16. // `len(oldOut) > 0` guard that protects the oldOut[0]/newOut[0] index. With no
  17. // outbounds on either side the first-outbound identity check must be SKIPPED
  18. // (an empty hot diff), never executed; a mutated guard (`>= 0`) would index a
  19. // nil slice and panic.
  20. func TestDiffOutbounds_EmptyOutboundsNoPanic(t *testing.T) {
  21. oldCfg := makeHotConfig()
  22. oldCfg.OutboundConfigs = nil
  23. newCfg := makeHotConfig()
  24. newCfg.OutboundConfigs = nil
  25. diff, ok := ComputeHotDiff(oldCfg, newCfg)
  26. if !ok {
  27. t.Fatal("identical empty-outbound configs must be hot-appliable")
  28. }
  29. if len(diff.RemovedOutboundTags) != 0 || len(diff.AddedOutbounds) != 0 {
  30. t.Fatalf("no outbounds on either side must yield no outbound ops, got %+v", diff)
  31. }
  32. }
  33. // TestDiffOutbounds_SingleFirstOutboundChangeNeedsRestart pins the other side
  34. // of the hot_diff.go:154 boundary. With exactly ONE outbound, changing its
  35. // content touches the default (first) handler, which has no replace API — it
  36. // must force a restart. A mutated guard (`> 1`) would skip the first-outbound
  37. // check at this length and wrongly classify the change as hot-appliable.
  38. func TestDiffOutbounds_SingleFirstOutboundChangeNeedsRestart(t *testing.T) {
  39. oldCfg := makeHotConfig()
  40. oldCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"}]`)
  41. newCfg := makeHotConfig()
  42. newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"UseIP"},"tag":"direct"}]`)
  43. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  44. t.Fatal("changing the only (default) outbound must force a restart")
  45. }
  46. }
  47. // TestRoutingWithoutReloadable_EmptyInput pins hot_diff.go:219 — the
  48. // `len(raw) > 0` guard that skips JSON decoding of empty input. Empty input
  49. // must canonicalize to the empty object `{}` with ok=true (no rules/balancers
  50. // to strip). A mutated guard (`>= 0`) would feed an empty reader to the JSON
  51. // decoder, get io.EOF, and wrongly return ok=false.
  52. func TestRoutingWithoutReloadable_EmptyInput(t *testing.T) {
  53. out, ok := routingWithoutReloadable([]byte{})
  54. if !ok {
  55. t.Fatal("empty routing input must canonicalize successfully")
  56. }
  57. if string(out) != "{}" {
  58. t.Fatalf("empty routing input must canonicalize to {}, got %q", out)
  59. }
  60. // nil input behaves the same as empty.
  61. out, ok = routingWithoutReloadable(nil)
  62. if !ok || string(out) != "{}" {
  63. t.Fatalf("nil routing input must canonicalize to {}, ok=%v out=%q", ok, out)
  64. }
  65. }
  66. // TestRoutingWithoutReloadable_StripsRulesAndBalancers complements the guard
  67. // test: with real content the reloadable keys (rules, balancers) are removed
  68. // and only the restart-only remainder is returned. This pins that a routing
  69. // change limited to rules/balancers leaves an identical remainder.
  70. func TestRoutingWithoutReloadable_StripsRulesAndBalancers(t *testing.T) {
  71. a, ok := routingWithoutReloadable([]byte(`{"domainStrategy":"AsIs","rules":[{"x":1}],"balancers":[{"y":2}]}`))
  72. if !ok {
  73. t.Fatal("valid routing input must parse")
  74. }
  75. b, ok := routingWithoutReloadable([]byte(`{"domainStrategy":"AsIs","rules":[],"balancers":[]}`))
  76. if !ok {
  77. t.Fatal("valid routing input must parse")
  78. }
  79. if string(a) != string(b) {
  80. t.Fatalf("rules/balancers must be stripped: %q != %q", a, b)
  81. }
  82. if string(a) != `{"domainStrategy":"AsIs"}` {
  83. t.Fatalf("remainder must keep only restart-only keys, got %q", a)
  84. }
  85. }
  86. // TestApiTagFromConfig pins hot_diff.go:357 — the three-part guard
  87. // `len(api) > 0 && Unmarshal == nil && parsed.Tag != ""`. Each conjunct must
  88. // hold for a custom tag to be honored; otherwise the default "api" is used.
  89. func TestApiTagFromConfig(t *testing.T) {
  90. cases := []struct {
  91. name string
  92. api string
  93. want string
  94. }{
  95. {"empty input falls back to api", "", "api"},
  96. {"explicit tag honored", `{"tag":"my-api"}`, "my-api"},
  97. {"empty tag falls back to api", `{"tag":""}`, "api"},
  98. {"missing tag falls back to api", `{"services":["StatsService"]}`, "api"},
  99. {"unparsable falls back to api", `{not-json`, "api"},
  100. }
  101. for _, tc := range cases {
  102. t.Run(tc.name, func(t *testing.T) {
  103. got := apiTagFromConfig(json_util.RawMessage(tc.api))
  104. if got != tc.want {
  105. t.Fatalf("apiTagFromConfig(%q) = %q, want %q", tc.api, got, tc.want)
  106. }
  107. })
  108. }
  109. }
  110. // TestApiTagDrivesInboundRestartGuard ties hot_diff.go:357 to its consumer:
  111. // the api tag resolved from the api section is the tag whose inbound change
  112. // forces a restart. With a custom api.tag, changing that inbound must NOT be
  113. // hot-appliable (it carries the gRPC server the panel talks through).
  114. func TestApiTagDrivesInboundRestartGuard(t *testing.T) {
  115. oldCfg := makeHotConfig()
  116. oldCfg.API = json_util.RawMessage(`{"services":["HandlerService"],"tag":"custom-api"}`)
  117. oldCfg.InboundConfigs[0].Tag = "custom-api"
  118. newCfg := makeHotConfig()
  119. newCfg.API = json_util.RawMessage(`{"services":["HandlerService"],"tag":"custom-api"}`)
  120. newCfg.InboundConfigs[0].Tag = "custom-api"
  121. newCfg.InboundConfigs[0].Port = 62790 // change the custom-api inbound
  122. if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
  123. t.Fatal("changing the inbound named by a custom api.tag must force a restart")
  124. }
  125. }
  126. // ---------------------------------------------------------------------------
  127. // process.go mutation audits (pure-logic, cross-platform)
  128. // ---------------------------------------------------------------------------
  129. // TestIsRunning_ExitedProcessWithClosedDone pins process.go:240 — the
  130. // `if p.done != nil` guard that decides whether to consult the done channel.
  131. // When the process has exited (done closed) but ProcessState has not yet been
  132. // observed, IsRunning must report false via the closed-channel select. A
  133. // mutated guard (`== nil`) would skip the select and wrongly report true.
  134. func TestIsRunning_ExitedProcessWithClosedDone(t *testing.T) {
  135. p := newProcess(nil)
  136. p.cmd = &exec.Cmd{Process: &os.Process{}}
  137. done := make(chan struct{})
  138. close(done)
  139. p.done = done
  140. if p.IsRunning() {
  141. t.Fatal("a process whose done channel is closed must report not running")
  142. }
  143. }
  144. // TestIsRunning_LiveProcessWithOpenDone is the complementary case: an open
  145. // done channel and no ProcessState means the process is alive, so IsRunning
  146. // must report true (the select's default branch is taken).
  147. func TestIsRunning_LiveProcessWithOpenDone(t *testing.T) {
  148. p := newProcess(nil)
  149. p.cmd = &exec.Cmd{Process: &os.Process{}}
  150. p.done = make(chan struct{}) // open
  151. if !p.IsRunning() {
  152. t.Fatal("a process with an open done channel and live cmd must report running")
  153. }
  154. }
  155. // TestGetResult pins process.go:260 — the
  156. // `if len(lastLine) == 0 && exitErr != nil` choice between the captured log
  157. // line and the exit error string.
  158. func TestGetResult(t *testing.T) {
  159. cases := []struct {
  160. name string
  161. lastLine string
  162. exitErr error
  163. want string
  164. }{
  165. {"no line, has error -> error string", "", errProcessTest("boom"), "boom"},
  166. {"has line -> line wins over error", "last log", errProcessTest("boom"), "last log"},
  167. {"no line, no error -> empty", "", nil, ""},
  168. {"has line, no error -> line", "last log", nil, "last log"},
  169. }
  170. for _, tc := range cases {
  171. t.Run(tc.name, func(t *testing.T) {
  172. p := newProcess(nil)
  173. p.logWriter.lastLine = tc.lastLine
  174. p.exitErr = tc.exitErr
  175. if got := p.GetResult(); got != tc.want {
  176. t.Fatalf("GetResult() = %q, want %q", got, tc.want)
  177. }
  178. })
  179. }
  180. }
  181. type errProcessTest string
  182. func (e errProcessTest) Error() string { return string(e) }
  183. // TestRefreshLocalOnline_GraceBoundaryEmails pins the exact `<` boundary at
  184. // process.go:407: an email idle for EXACTLY graceMs must be aged out (the
  185. // window is half-open, age < grace). A mutated comparison (`<=`) would keep it.
  186. func TestRefreshLocalOnline_GraceBoundaryEmails(t *testing.T) {
  187. p := newOnlineTestProcess()
  188. const grace = int64(20000)
  189. p.RefreshLocalOnline([]string{"edge"}, nil, 0, grace)
  190. // now-ts == grace exactly: age is not strictly < grace, so it must drop.
  191. p.RefreshLocalOnline(nil, nil, grace, grace)
  192. for _, e := range p.GetLocalOnlineClients() {
  193. if e == "edge" {
  194. t.Fatalf("email idle exactly graceMs must age out (half-open window), got online %v", p.GetLocalOnlineClients())
  195. }
  196. }
  197. // One millisecond inside the window must still be online.
  198. p2 := newOnlineTestProcess()
  199. p2.RefreshLocalOnline([]string{"edge"}, nil, 0, grace)
  200. p2.RefreshLocalOnline(nil, nil, grace-1, grace)
  201. if !containsString(p2.GetLocalOnlineClients(), "edge") {
  202. t.Fatalf("email idle graceMs-1 must still be online, got %v", p2.GetLocalOnlineClients())
  203. }
  204. }
  205. // TestRefreshLocalOnline_GraceBoundaryInbounds pins the same `<` boundary at
  206. // process.go:423 for inbound tags.
  207. func TestRefreshLocalOnline_GraceBoundaryInbounds(t *testing.T) {
  208. p := newOnlineTestProcess()
  209. const grace = int64(20000)
  210. p.RefreshLocalOnline(nil, []string{"in-edge"}, 0, grace)
  211. p.RefreshLocalOnline(nil, nil, grace, grace)
  212. for _, tag := range p.GetLocalActiveInbounds() {
  213. if tag == "in-edge" {
  214. t.Fatalf("inbound idle exactly graceMs must age out, got active %v", p.GetLocalActiveInbounds())
  215. }
  216. }
  217. p2 := newOnlineTestProcess()
  218. p2.RefreshLocalOnline(nil, []string{"in-edge"}, 0, grace)
  219. p2.RefreshLocalOnline(nil, nil, grace-1, grace)
  220. if !containsString(p2.GetLocalActiveInbounds(), "in-edge") {
  221. t.Fatalf("inbound idle graceMs-1 must still be active, got %v", p2.GetLocalActiveInbounds())
  222. }
  223. }
  224. func containsString(s []string, v string) bool {
  225. return slices.Contains(s, v)
  226. }
  227. // ---------------------------------------------------------------------------
  228. // process.go mutation audits (require a real child process; re-invoke the
  229. // test binary so they run cross-platform, no signals needed)
  230. // ---------------------------------------------------------------------------
  231. // TestWaitForCommand_CrashExitRecordsError pins process.go:554 — the
  232. // `if err == nil || intentionalStop` guard. A process that exits with a
  233. // NON-zero code on its own (not an intentional Stop) is a crash and its error
  234. // MUST be recorded. A mutated guard that negates the err check (`err != nil`)
  235. // would early-return and drop the error.
  236. func TestWaitForCommand_CrashExitRecordsError(t *testing.T) {
  237. t.Setenv("XUI_LOG_FOLDER", t.TempDir())
  238. cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "crash-exit")
  239. cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
  240. p := newProcess(nil)
  241. if err := p.startCommand(cmd); err != nil {
  242. t.Fatalf("startCommand: %v", err)
  243. }
  244. // We never call Stop -> intentionalStop stays false; the child exits 2.
  245. if err := p.waitForExit(5 * time.Second); err != nil {
  246. t.Fatalf("child did not exit: %v", err)
  247. }
  248. if p.GetErr() == nil {
  249. t.Fatal("a non-intentional non-zero exit must record an error")
  250. }
  251. }
  252. // A real child panic reaches the LogWriter in several pipe chunks; the crash
  253. // report is only whole if waitForCommand flushes the writer after cmd.Wait.
  254. func TestWaitForCommand_PanicWritesWholeCrashReport(t *testing.T) {
  255. dir := t.TempDir()
  256. t.Setenv("XUI_LOG_FOLDER", dir)
  257. cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "panic")
  258. cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
  259. p := newProcess(nil)
  260. cmd.Stdout = p.logWriter
  261. cmd.Stderr = p.logWriter
  262. if err := p.startCommand(cmd); err != nil {
  263. t.Fatalf("startCommand: %v", err)
  264. }
  265. if err := p.waitForExit(5 * time.Second); err != nil {
  266. t.Fatalf("child did not exit: %v", err)
  267. }
  268. reports := crashReportsIn(t, dir)
  269. if len(reports) != 1 || !strings.HasPrefix(reports[0], "panic: xray helper panic\n\ngoroutine ") {
  270. t.Fatalf("crash reports = %q, want one holding the whole panic traceback", reports)
  271. }
  272. if got := p.GetResult(); got != "panic: xray helper panic" {
  273. t.Fatalf("GetResult = %q, want the panic header", got)
  274. }
  275. }
  276. // TestStop_RemovesTempConfigFile pins process.go:579 — the
  277. // `if p.configPath != ""` guard that removes the per-run temp config file on
  278. // Stop (so test runs never disturb the main config.json). A mutated guard
  279. // (`== ""`) would skip the removal and leak the temp file.
  280. func TestStop_RemovesTempConfigFile(t *testing.T) {
  281. t.Setenv("XUI_LOG_FOLDER", t.TempDir())
  282. tmpCfg := filepath.Join(t.TempDir(), "test-config.json")
  283. if err := os.WriteFile(tmpCfg, []byte("{}"), 0o644); err != nil {
  284. t.Fatalf("write temp config: %v", err)
  285. }
  286. cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "block")
  287. cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
  288. p := newProcess(nil)
  289. p.configPath = tmpCfg
  290. if err := p.startCommand(cmd); err != nil {
  291. t.Fatalf("startCommand: %v", err)
  292. }
  293. t.Cleanup(func() {
  294. if p.IsRunning() {
  295. p.intentionalStop.Store(true)
  296. _ = p.cmd.Process.Kill()
  297. _ = p.waitForExit(2 * time.Second)
  298. }
  299. })
  300. if !p.IsRunning() {
  301. t.Fatal("helper process must be running before Stop")
  302. }
  303. if err := p.Stop(); err != nil {
  304. t.Fatalf("Stop: %v", err)
  305. }
  306. if _, err := os.Stat(tmpCfg); !os.IsNotExist(err) {
  307. t.Fatalf("temp config file must be removed on Stop, stat err=%v", err)
  308. }
  309. }
  310. // TestMutationAuditHelper is the re-invoked child for the process tests above.
  311. // It is inert unless XRAY_MUT_HELPER=1 is set.
  312. func TestMutationAuditHelper(t *testing.T) {
  313. if os.Getenv("XRAY_MUT_HELPER") != "1" {
  314. return
  315. }
  316. mode := ""
  317. for i, arg := range os.Args {
  318. if arg == "--" && i+1 < len(os.Args) {
  319. mode = os.Args[i+1]
  320. break
  321. }
  322. }
  323. switch mode {
  324. case "crash-exit":
  325. os.Exit(2)
  326. case "block":
  327. select {}
  328. case "panic":
  329. go func() { panic("xray helper panic") }()
  330. select {}
  331. }
  332. }