mutation_audit_test.go 12 KB

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