mutation_audit_test.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. package sub
  2. import (
  3. "encoding/base64"
  4. "encoding/json"
  5. "path/filepath"
  6. "strings"
  7. "testing"
  8. "time"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  12. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  13. )
  14. // initMutDB spins up a real temp SQLite DB for tests that exercise DB-backed
  15. // query helpers, mirroring the house pattern in service_sharelink/dedup tests.
  16. func initMutDB(t *testing.T) {
  17. t.Helper()
  18. dbDir := t.TempDir()
  19. t.Setenv("XUI_DB_FOLDER", dbDir)
  20. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  21. }
  22. // --- json_service.go:40 — rules are merged into routing only when non-empty ---
  23. func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
  24. rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
  25. svc := NewSubJsonService("", rules, "", "", nil)
  26. routing, ok := svc.configJson["routing"].(map[string]any)
  27. if !ok {
  28. t.Fatalf("routing missing: %#v", svc.configJson["routing"])
  29. }
  30. got, _ := routing["rules"].([]any)
  31. // default.json ships exactly 1 rule; the custom rule must be prepended.
  32. if len(got) != 2 {
  33. t.Fatalf("rules len = %d, want 2 (custom prepended to default)", len(got))
  34. }
  35. first, _ := got[0].(map[string]any)
  36. if domains, _ := first["domain"].([]any); len(domains) != 1 || domains[0] != "geosite:ads" {
  37. t.Fatalf("custom rule must come first, got %#v", got[0])
  38. }
  39. }
  40. func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
  41. svc := NewSubJsonService("", "", "", "", nil)
  42. routing, _ := svc.configJson["routing"].(map[string]any)
  43. got, _ := routing["rules"].([]any)
  44. if len(got) != 1 {
  45. t.Fatalf("rules len = %d, want 1 (no custom rules → default untouched)", len(got))
  46. }
  47. }
  48. // --- json_service.go:331,356,408 — mux is attached only when configured ---
  49. func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
  50. const mux = `{"enabled":true,"concurrency":8}`
  51. client := model.Client{ID: "uuid-1", Password: "p4ss"}
  52. cases := []struct {
  53. name string
  54. raw []byte
  55. wantMux bool
  56. protocol model.Protocol
  57. }{
  58. {"vmess mux", NewSubJsonService(mux, "", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
  59. {"vless mux", NewSubJsonService(mux, "", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
  60. {"server mux", NewSubJsonService(mux, "", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
  61. {"vmess no mux", NewSubJsonService("", "", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
  62. {"vless no mux", NewSubJsonService("", "", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
  63. {"server no mux", NewSubJsonService("", "", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
  64. }
  65. for _, tc := range cases {
  66. t.Run(tc.name, func(t *testing.T) {
  67. var ob map[string]any
  68. if err := json.Unmarshal(tc.raw, &ob); err != nil {
  69. t.Fatalf("unmarshal outbound: %v", err)
  70. }
  71. m, has := ob["mux"]
  72. if tc.wantMux {
  73. if !has {
  74. t.Fatalf("mux must be set when configured, outbound = %#v", ob)
  75. }
  76. mm, _ := m.(map[string]any)
  77. if mm["enabled"] != true || mm["concurrency"] != float64(8) {
  78. t.Fatalf("mux payload wrong: %#v", m)
  79. }
  80. } else if has {
  81. t.Fatalf("mux must be omitted when empty, outbound = %#v", ob)
  82. }
  83. })
  84. }
  85. }
  86. // --- applyGlobalFinalMask — a non-empty finalMask that merges to nothing must
  87. // not add the finalmask key (the `len(merged) > 0` guard). ---
  88. func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
  89. // finalMask is non-empty (passes the len(fm)==0 early return) but its only
  90. // key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
  91. // so applyGlobalFinalMask must NOT set finalmask.
  92. svc := NewSubJsonService("", "", `{"tcp":[]}`, "", nil)
  93. stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
  94. if _, ok := stream["finalmask"]; ok {
  95. t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
  96. }
  97. // Sanity: a finalMask that DOES merge to something still gets set, so the
  98. // guard is the only distinguishing factor.
  99. svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, "", nil)
  100. stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
  101. if _, ok := stream2["finalmask"]; !ok {
  102. t.Fatal("non-empty finalMask must be set")
  103. }
  104. }
  105. // --- mergeFinalMask — an empty extra tcp slice must not clobber the base ---
  106. func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) {
  107. base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}}
  108. extra := map[string]any{"tcp": []any{}} // empty → must be ignored
  109. merged := mergeFinalMask(base, extra)
  110. tcp, _ := merged["tcp"].([]any)
  111. if len(tcp) != 1 {
  112. t.Fatalf("tcp len = %d, want 1 (empty extra must not drop or append)", len(tcp))
  113. }
  114. if first, _ := tcp[0].(map[string]any); first["type"] != "keep" {
  115. t.Fatalf("base tcp mask lost: %#v", tcp)
  116. }
  117. // Sanity: a non-empty extra DOES append, so the guard is the only thing
  118. // distinguishing the two paths.
  119. extra2 := map[string]any{"tcp": []any{map[string]any{"type": "add"}}}
  120. merged2 := mergeFinalMask(base, extra2)
  121. if tcp2, _ := merged2["tcp"].([]any); len(tcp2) != 2 {
  122. t.Fatalf("non-empty extra must append: len = %d, want 2", len(tcp2))
  123. }
  124. }
  125. // --- service.go:69-77 — configuredPublicHost priority: subDomain > webDomain > "" ---
  126. func TestConfiguredPublicHost_Priority(t *testing.T) {
  127. initMutDB(t)
  128. db := database.GetDB()
  129. set := func(key, val string) {
  130. if err := db.Save(&model.Setting{Key: key, Value: val}).Error; err != nil {
  131. t.Fatalf("save %s: %v", key, err)
  132. }
  133. }
  134. s := &SubService{}
  135. // Both empty → "".
  136. if got := s.configuredPublicHost(); got != "" {
  137. t.Fatalf("no domains configured: got %q, want empty", got)
  138. }
  139. // Only webDomain → webDomain wins (exercises the second branch, service.go:73).
  140. set("webDomain", "web.example.com")
  141. if got := s.configuredPublicHost(); got != "web.example.com" {
  142. t.Fatalf("webDomain fallback: got %q, want web.example.com", got)
  143. }
  144. // subDomain set → subDomain takes precedence over webDomain (service.go:70).
  145. set("subDomain", "sub.example.com")
  146. if got := s.configuredPublicHost(); got != "sub.example.com" {
  147. t.Fatalf("subDomain priority: got %q, want sub.example.com", got)
  148. }
  149. }
  150. // --- service.go:248 — AggregateTrafficByEmails tracks the MAX LastOnline ---
  151. func TestAggregateTrafficByEmails_LastOnlineIsMax(t *testing.T) {
  152. initMutDB(t)
  153. db := database.GetDB()
  154. rows := []xray.ClientTraffic{
  155. {Email: "a@x", Up: 10, Down: 20, LastOnline: 100},
  156. {Email: "b@x", Up: 1, Down: 2, LastOnline: 500}, // the max
  157. {Email: "c@x", Up: 3, Down: 4, LastOnline: 300},
  158. }
  159. for i := range rows {
  160. if err := db.Create(&rows[i]).Error; err != nil {
  161. t.Fatalf("seed traffic: %v", err)
  162. }
  163. }
  164. s := &SubService{}
  165. agg, lastOnline := s.AggregateTrafficByEmails([]string{"a@x", "b@x", "c@x"})
  166. if lastOnline != 500 {
  167. t.Fatalf("lastOnline = %d, want 500 (max across rows)", lastOnline)
  168. }
  169. // Up/Down must still sum so a mutant can't pass by zeroing everything.
  170. if agg.Up != 14 || agg.Down != 26 {
  171. t.Fatalf("agg up/down = %d/%d, want 14/26", agg.Up, agg.Down)
  172. }
  173. }
  174. // --- service.go:329 — projectThroughFallbackMaster returns false for nil ---
  175. func TestProjectThroughFallbackMaster_Nil(t *testing.T) {
  176. s := &SubService{}
  177. if s.projectThroughFallbackMaster(nil) {
  178. t.Fatal("nil inbound must yield false (no projection, no DB hit)")
  179. }
  180. }
  181. // --- service.go:555 — empty client flow must not emit a flow param even when allowed ---
  182. func TestGenVlessLink_NoFlowWhenClientFlowEmpty(t *testing.T) {
  183. // tcp+reality is a flow-allowed combo; with an empty client flow the
  184. // len(...)>0 guard (service.go:555) must keep `flow` out of the link.
  185. stream := `{
  186. "network":"tcp","security":"reality",
  187. "tcpSettings":{"header":{"type":"none"}},
  188. "realitySettings":{"serverNames":["r.example.com"],"shortIds":["ab"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}
  189. }`
  190. inbound := &model.Inbound{
  191. Listen: "203.0.113.1",
  192. Port: 443,
  193. Protocol: model.VLESS,
  194. Remark: "noflow",
  195. Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"encryption":"none"}`,
  196. StreamSettings: stream,
  197. }
  198. s := &SubService{}
  199. if link := s.genVlessLink(inbound, "user"); strings.Contains(link, "flow=") {
  200. t.Fatalf("empty client flow must not produce a flow param, got %q", link)
  201. }
  202. }
  203. // --- service.go:906-913 — applyPathAndHostParams host source ---
  204. func TestApplyPathAndHostParams(t *testing.T) {
  205. // Direct host wins (service.go:908 true branch).
  206. params := map[string]string{}
  207. applyPathAndHostParams(map[string]any{"path": "/p", "host": "direct.example.com"}, params)
  208. if params["path"] != "/p" {
  209. t.Fatalf("path = %q, want /p", params["path"])
  210. }
  211. if params["host"] != "direct.example.com" {
  212. t.Fatalf("direct host = %q, want direct.example.com", params["host"])
  213. }
  214. // No direct host → fall back to headers.Host (service.go:908 false branch).
  215. params = map[string]string{}
  216. applyPathAndHostParams(map[string]any{
  217. "path": "/p",
  218. "headers": map[string]any{"Host": "via-header.example.com"},
  219. }, params)
  220. if params["host"] != "via-header.example.com" {
  221. t.Fatalf("header host fallback = %q, want via-header.example.com", params["host"])
  222. }
  223. // Empty-string host must NOT shadow the header fallback (len(host) > 0 guard).
  224. params = map[string]string{}
  225. applyPathAndHostParams(map[string]any{
  226. "path": "/p",
  227. "host": "",
  228. "headers": map[string]any{"Host": "via-header.example.com"},
  229. }, params)
  230. if params["host"] != "via-header.example.com" {
  231. t.Fatalf("empty host must defer to headers, got %q", params["host"])
  232. }
  233. }
  234. // --- external_config.go:39,42,55,58 — getClientExternalLinksBySubId ---
  235. func TestGetClientExternalLinksBySubId(t *testing.T) {
  236. initMutDB(t)
  237. db := database.GetDB()
  238. s := &SubService{}
  239. // No client rows for the subId → nil, no error (service.go path :42).
  240. out, err := s.getClientExternalLinksBySubId("missing")
  241. if err != nil {
  242. t.Fatalf("missing subId err = %v, want nil", err)
  243. }
  244. if out != nil {
  245. t.Fatalf("missing subId = %#v, want nil", out)
  246. }
  247. // A client with NO external-link rows → nil (the rows-empty guard :58).
  248. bare := &model.ClientRecord{Email: "bare@x", SubID: "sub-bare", UUID: "u", Enable: true}
  249. if err := db.Create(bare).Error; err != nil {
  250. t.Fatalf("seed bare client: %v", err)
  251. }
  252. out, err = s.getClientExternalLinksBySubId("sub-bare")
  253. if err != nil {
  254. t.Fatalf("bare subId err = %v", err)
  255. }
  256. if out != nil {
  257. t.Fatalf("client with no links = %#v, want nil", out)
  258. }
  259. // A client with two link rows: ordering by sort_index and email/enable
  260. // attribution from the owning client (the loop copies rec.Email/rec.Enable).
  261. rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true, ExpiryTime: time.Now().Add(time.Hour).UnixMilli()}
  262. if err := db.Create(rec).Error; err != nil {
  263. t.Fatalf("seed client: %v", err)
  264. }
  265. if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://b", Remark: "second", SortIndex: 5}).Error; err != nil {
  266. t.Fatalf("seed link b: %v", err)
  267. }
  268. if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil {
  269. t.Fatalf("seed link a: %v", err)
  270. }
  271. if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://disabled", Remark: "disabled", Enable: new(false), SortIndex: 3}).Error; err != nil {
  272. t.Fatalf("seed disabled link: %v", err)
  273. }
  274. if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://expired", Remark: "expired", ExpiryTime: time.Now().Add(-time.Hour).UnixMilli(), SortIndex: 4}).Error; err != nil {
  275. t.Fatalf("seed expired link: %v", err)
  276. }
  277. out, err = s.getClientExternalLinksBySubId("sub-ok")
  278. if err != nil {
  279. t.Fatalf("ok subId err = %v", err)
  280. }
  281. if len(out) != 2 {
  282. t.Fatalf("entries = %d, want 2", len(out))
  283. }
  284. // sort_index ASC: the SortIndex=1 row comes first.
  285. if out[0].Value != "trojan://a" || out[1].Value != "trojan://b" {
  286. t.Fatalf("ordering wrong: %#v", out)
  287. }
  288. // Email + Enable must be copied from the owning client, not the link row
  289. // (which carries neither field). The enabled owner → Enable true.
  290. if out[0].Email != "owner@x" || out[0].Enable != true {
  291. t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable)
  292. }
  293. if !out[0].Active {
  294. t.Fatal("active owner marked inactive")
  295. }
  296. // A disabled owner stays visible as metadata but cannot expose its link.
  297. // Enable has a gorm default:true, so update it after insertion.
  298. dis := &model.ClientRecord{Email: "off@x", SubID: "sub-off", UUID: "u3", Enable: true}
  299. if err := db.Create(dis).Error; err != nil {
  300. t.Fatalf("seed disabled client: %v", err)
  301. }
  302. if err := db.Model(&model.ClientRecord{}).Where("id = ?", dis.Id).Update("enable", false).Error; err != nil {
  303. t.Fatalf("disable client: %v", err)
  304. }
  305. if err := db.Create(&model.ClientExternalLink{ClientId: dis.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://c", SortIndex: 1}).Error; err != nil {
  306. t.Fatalf("seed link c: %v", err)
  307. }
  308. offOut, err := s.getClientExternalLinksBySubId("sub-off")
  309. if err != nil {
  310. t.Fatalf("off subId err = %v", err)
  311. }
  312. if len(offOut) != 1 {
  313. t.Fatalf("disabled client entries = %d, want 1", len(offOut))
  314. }
  315. if offOut[0].Enable || offOut[0].Active {
  316. t.Fatalf("disabled owner state = enable:%v active:%v", offOut[0].Enable, offOut[0].Active)
  317. }
  318. expired := &model.ClientRecord{Email: "expired@x", SubID: "sub-expired", UUID: "u4", Enable: true, ExpiryTime: time.Now().Add(-time.Hour).UnixMilli()}
  319. if err := db.Create(expired).Error; err != nil {
  320. t.Fatalf("seed expired client: %v", err)
  321. }
  322. if err := db.Create(&model.ClientExternalLink{ClientId: expired.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://d", SortIndex: 1}).Error; err != nil {
  323. t.Fatalf("seed expired client link: %v", err)
  324. }
  325. expiredOut, err := s.getClientExternalLinksBySubId("sub-expired")
  326. if err != nil {
  327. t.Fatalf("expired subId err = %v", err)
  328. }
  329. if len(expiredOut) != 1 {
  330. t.Fatalf("expired client entries = %d, want 1", len(expiredOut))
  331. }
  332. if !expiredOut[0].Enable || expiredOut[0].Active {
  333. t.Fatalf("expired owner state = enable:%v active:%v", expiredOut[0].Enable, expiredOut[0].Active)
  334. }
  335. }
  336. // --- external_config.go:102 — applyRemarkToLink appends a fragment when none exists ---
  337. func TestApplyRemarkToLink_NoFragmentAppends(t *testing.T) {
  338. link := "trojan://[email protected]:8443?security=tls"
  339. out := applyRemarkToLink(link, "DE-Node")
  340. if out != link+"#DE-Node" {
  341. t.Fatalf("no-fragment link must get the remark appended, got %q", out)
  342. }
  343. }
  344. // --- external_config.go:111 — applyVmessRemark falls back to RawURLEncoding ---
  345. func TestApplyVmessRemark_RawURLEncodingFallback(t *testing.T) {
  346. // The "aa?" ps forces a URL-safe char (_) in the RawURL encoding, so
  347. // base64.StdEncoding.DecodeString fails and the RawURLEncoding fallback
  348. // path (external_config.go:111) must take over. (ps is overwritten below,
  349. // so its value is irrelevant to the assertions.)
  350. payload := map[string]any{"v": "2", "ps": "aa?", "add": "1.2.3.4", "port": "443", "id": "uuid"}
  351. b, _ := json.Marshal(payload)
  352. link := "vmess://" + base64.RawURLEncoding.EncodeToString(b)
  353. // Guard the premise: this link must NOT be std-decodable, else the fallback
  354. // branch is never reached and the test is meaningless.
  355. if _, err := base64.StdEncoding.DecodeString(padBase64Sub(strings.TrimPrefix(link, "vmess://"))); err == nil {
  356. t.Fatal("test premise broken: link is std-base64 decodable, fallback not exercised")
  357. }
  358. out := applyRemarkToLink(link, "NL-Node")
  359. if out == link {
  360. t.Fatalf("raw-url-encoded vmess remark was not applied (fallback decode broken): %q", out)
  361. }
  362. // The result re-encodes with StdEncoding; decode and verify ps + credentials.
  363. raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(out, "vmess://"))
  364. if err != nil {
  365. t.Fatalf("decode out: %v", err)
  366. }
  367. var got map[string]any
  368. if err := json.Unmarshal(raw, &got); err != nil {
  369. t.Fatalf("unmarshal: %v", err)
  370. }
  371. if got["ps"] != "NL-Node" {
  372. t.Fatalf("ps = %v, want NL-Node", got["ps"])
  373. }
  374. if got["id"] != "uuid" {
  375. t.Fatalf("credentials lost via fallback path: %#v", got)
  376. }
  377. }
  378. // --- external_config.go:130 — padBase64Sub pads to a multiple of 4 ---
  379. func TestPadBase64Sub(t *testing.T) {
  380. cases := map[string]string{
  381. "": "",
  382. "a": "a===",
  383. "ab": "ab==",
  384. "abc": "abc=",
  385. "abcd": "abcd",
  386. }
  387. for in, want := range cases {
  388. if got := padBase64Sub(in); got != want {
  389. t.Fatalf("padBase64Sub(%q) = %q, want %q", in, got, want)
  390. }
  391. if len(padBase64Sub(in))%4 != 0 {
  392. t.Fatalf("padBase64Sub(%q) length not a multiple of 4", in)
  393. }
  394. }
  395. }
  396. // --- external_subscription.go:122 — base64 body decode strips embedded whitespace ---
  397. func TestDecodeSubscriptionBody_StripsWhitespaceInBase64(t *testing.T) {
  398. plain := "vless://[email protected]:443#one\ntrojan://[email protected]:8443#two\n"
  399. encoded := base64.StdEncoding.EncodeToString([]byte(plain))
  400. // Inject whitespace into the base64 token; tryDecodeBase64Body must strip it
  401. // (external_subscription.go:122) so decoding still succeeds.
  402. half := len(encoded) / 2
  403. dirty := encoded[:half] + "\n \t" + encoded[half:]
  404. links := decodeSubscriptionBody([]byte(dirty))
  405. if len(links) != 2 || links[0] != "vless://[email protected]:443#one" || links[1] != "trojan://[email protected]:8443#two" {
  406. t.Fatalf("whitespace-laden base64 body decoded wrong: %#v", links)
  407. }
  408. }
  409. // --- clash_service.go:123 — duplicate proxy names disambiguate as base-N ---
  410. func TestEnsureUniqueProxyNames_SuffixSequence(t *testing.T) {
  411. proxies := []map[string]any{
  412. {"name": "node"},
  413. {"name": "node"},
  414. {"name": "node"},
  415. }
  416. ensureUniqueProxyNames(proxies)
  417. if proxies[0]["name"] != "node" {
  418. t.Fatalf("first occurrence must keep base name, got %v", proxies[0]["name"])
  419. }
  420. if proxies[1]["name"] != "node-2" {
  421. t.Fatalf("second duplicate = %v, want node-2", proxies[1]["name"])
  422. }
  423. if proxies[2]["name"] != "node-3" {
  424. t.Fatalf("third duplicate = %v, want node-3", proxies[2]["name"])
  425. }
  426. }
  427. // --- clash_service.go:422,447 — empty transport opts must NOT add the *-opts key ---
  428. func TestApplyTransport_EmptyOptsOmitted(t *testing.T) {
  429. svc := &SubClashService{}
  430. // httpupgrade with no path/host → opts empty → no http-upgrade-opts key (clash:422).
  431. huProxy := map[string]any{}
  432. if !svc.applyTransport(huProxy, "httpupgrade", map[string]any{"httpupgradeSettings": map[string]any{}}) {
  433. t.Fatal("httpupgrade must still be buildable")
  434. }
  435. if huProxy["network"] != "httpupgrade" {
  436. t.Fatalf("network = %v, want httpupgrade", huProxy["network"])
  437. }
  438. if _, ok := huProxy["http-upgrade-opts"]; ok {
  439. t.Fatalf("empty opts must not set http-upgrade-opts: %#v", huProxy["http-upgrade-opts"])
  440. }
  441. // xhttp with no path/host/mode → opts empty → no xhttp-opts key (clash:447).
  442. xhProxy := map[string]any{}
  443. if !svc.applyTransport(xhProxy, "xhttp", map[string]any{"xhttpSettings": map[string]any{}}) {
  444. t.Fatal("xhttp must still be buildable")
  445. }
  446. if xhProxy["network"] != "xhttp" {
  447. t.Fatalf("network = %v, want xhttp", xhProxy["network"])
  448. }
  449. if _, ok := xhProxy["xhttp-opts"]; ok {
  450. t.Fatalf("empty opts must not set xhttp-opts: %#v", xhProxy["xhttp-opts"])
  451. }
  452. }