remark_vars_test.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  1. package sub
  2. import (
  3. "net/url"
  4. "strings"
  5. "testing"
  6. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  7. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  8. )
  9. const gb = int64(1024 * 1024 * 1024)
  10. // expandCtx builds a remarkContext from explicit pieces for token tests.
  11. func expandCtx(client model.Client, stats xray.ClientTraffic, inbound *model.Inbound) remarkContext {
  12. return remarkContext{client: client, stats: stats, inbound: inbound}
  13. }
  14. func TestExpandRemarkVars(t *testing.T) {
  15. inbound := &model.Inbound{Remark: "Germany"}
  16. client := model.Client{
  17. Email: "[email protected]",
  18. ID: "3f2a9c1b-aaaa-bbbb-cccc-1234567890ab",
  19. TgID: 123456789,
  20. SubID: "subABC",
  21. Comment: "vip",
  22. Reset: 30,
  23. CreatedAt: 1_700_000_000_000,
  24. }
  25. // 50GB total, 8GB used (5 up + 3 down), enabled, no expiry.
  26. stats := xray.ClientTraffic{
  27. Enable: true,
  28. Total: 50 * gb,
  29. Up: 5 * gb,
  30. Down: 3 * gb,
  31. }
  32. ctx := expandCtx(client, stats, inbound)
  33. cases := []struct{ tmpl, want string }{
  34. {"{{EMAIL}}", "[email protected]"},
  35. {"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
  36. {"{{HOST}}", ""}, // no host remark in ctx → empty
  37. {"{{ID}}", client.ID},
  38. {"{{SHORT_ID}}", "3f2a9c1b"},
  39. {"{{TELEGRAM_ID}}", "123456789"},
  40. {"{{SUB_ID}}", "subABC"},
  41. {"{{COMMENT}}", "vip"},
  42. {"{{RESET_DAYS}}", "30"},
  43. {"{{CREATED_UNIX}}", "1700000000"},
  44. {"{{TRAFFIC_USED}}", "8.00GB"},
  45. {"{{TRAFFIC_LEFT}}", "42.00GB"},
  46. {"{{TRAFFIC_TOTAL}}", "50.00GB"},
  47. {"{{TRAFFIC_USED_BYTES}}", "8589934592"},
  48. {"{{TRAFFIC_TOTAL_BYTES}}", "53687091200"},
  49. {"{{UP}}", "5.00GB"},
  50. {"{{DOWN}}", "3.00GB"},
  51. {"{{STATUS}}", "active"},
  52. {"{{EXPIRE_UNIX}}", "0"}, // no expiry
  53. {"{{EXPIRE_DATE}}", ""}, // no fixed date
  54. {"{{UNKNOWN_TOKEN}}", ""}, // unknown → empty, never literal
  55. {"DE {{EMAIL}} ok", "DE [email protected] ok"},
  56. {"{{EMAIL}}-{{SHORT_ID}}", "[email protected]"},
  57. {"no tokens here", "no tokens here"},
  58. }
  59. for _, c := range cases {
  60. if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
  61. t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
  62. }
  63. }
  64. // The unlimited tokens still render ∞ at the value layer; expandRemarkVars
  65. // is what drops an all-unlimited segment (see TestExpandRemarkVars_DropUnlimitedSegments).
  66. if got := remarkVarValue("DAYS_LEFT", ctx); got != "∞" {
  67. t.Errorf("remarkVarValue(DAYS_LEFT) = %q, want ∞", got)
  68. }
  69. }
  70. func TestExpandRemarkVars_EdgeCases(t *testing.T) {
  71. // Unlimited total → ∞ for human forms, 0 bytes for *_BYTES left. Checked at
  72. // the value layer: expandRemarkVars would drop a bare ∞ segment.
  73. unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 0, Up: gb}, nil)
  74. if got := remarkVarValue("TRAFFIC_TOTAL", unlimited); got != "∞" {
  75. t.Errorf("unlimited TRAFFIC_TOTAL = %q, want ∞", got)
  76. }
  77. if got := remarkVarValue("TRAFFIC_LEFT", unlimited); got != "∞" {
  78. t.Errorf("unlimited TRAFFIC_LEFT = %q, want ∞", got)
  79. }
  80. if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", unlimited); got != "0" {
  81. t.Errorf("unlimited TRAFFIC_LEFT_BYTES = %q, want 0", got)
  82. }
  83. // TgID zero → empty.
  84. if got := expandRemarkVars("{{TELEGRAM_ID}}", unlimited); got != "" {
  85. t.Errorf("zero TgID = %q, want empty", got)
  86. }
  87. // Over-quota usage clamps left to 0, not negative.
  88. over := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: gb, Up: 2 * gb}, nil)
  89. if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", over); got != "0" {
  90. t.Errorf("over-quota TRAFFIC_LEFT_BYTES = %q, want 0", got)
  91. }
  92. // Delayed-start (negative expiry) gives deterministic whole days.
  93. delayed := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, ExpiryTime: -864_000_000}, nil)
  94. if got := expandRemarkVars("{{DAYS_LEFT}}", delayed); got != "10" {
  95. t.Errorf("delayed-start DAYS_LEFT = %q, want 10", got)
  96. }
  97. }
  98. // defaultRemarkTemplate mirrors the panel's shipped remark template, the one an
  99. // inbound with no remark used to render with a leading hyphen.
  100. const defaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
  101. func TestExpandRemarkVars_DropsHyphenBetweenEmptyTokens(t *testing.T) {
  102. cases := []struct {
  103. name string
  104. tmpl string
  105. inbound string
  106. email string
  107. want string
  108. }{
  109. {name: "both values", tmpl: "{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "Germany-john"},
  110. {name: "empty inbound", tmpl: "{{INBOUND}}-{{EMAIL}}", email: "john", want: "john"},
  111. {name: "empty email", tmpl: "{{INBOUND}} - {{EMAIL}}", inbound: "Germany", want: "Germany"},
  112. {name: "literal leading hyphen", tmpl: "-{{EMAIL}}", email: "john", want: "-john"},
  113. {name: "literal leading hyphen before an empty token", tmpl: "-{{INBOUND}}-{{EMAIL}}", email: "john", want: "-john"},
  114. {name: "empty var between two values keeps one separator", tmpl: "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}", email: "john", want: "john-john"},
  115. {name: "emoji decoration before an empty token", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", email: "john", want: "🌐john"},
  116. {name: "literal word before an empty token", tmpl: "Sub {{INBOUND}}-{{EMAIL}}", email: "john", want: "Sub john"},
  117. {name: "decoration kept when both tokens resolve", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "🌐Germany-john"},
  118. {name: "default template, empty inbound", tmpl: defaultRemarkTemplate, email: "john", want: "john"},
  119. {name: "default template, empty email", tmpl: defaultRemarkTemplate, inbound: "Germany", want: "Germany"},
  120. }
  121. for _, tt := range cases {
  122. t.Run(tt.name, func(t *testing.T) {
  123. ctx := expandCtx(model.Client{Email: tt.email}, xray.ClientTraffic{Enable: true}, &model.Inbound{Remark: tt.inbound})
  124. if got := expandRemarkVars(tt.tmpl, ctx); got != tt.want {
  125. t.Errorf("expandRemarkVars(%q) = %q, want %q", tt.tmpl, got, tt.want)
  126. }
  127. })
  128. }
  129. }
  130. // An unlimited client drops the quota/expiry segments whole — decoration and the
  131. // "|" separator included — instead of printing "📊∞|⏳∞D".
  132. func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
  133. const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
  134. inbound := &model.Inbound{Remark: "host"}
  135. // No limit at all → only the name segment survives.
  136. unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
  137. if got := expandRemarkVars(tmpl, unlimited); got != "host" {
  138. t.Errorf("fully unlimited = %q, want %q", got, "host")
  139. }
  140. // Limited traffic but no expiry → traffic stays, the expiry segment drops.
  141. noExpiry := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 50 * gb, Up: 8 * gb}, inbound)
  142. if got := expandRemarkVars(tmpl, noExpiry); got != "host|📊42.00GB" {
  143. t.Errorf("no-expiry = %q, want %q", got, "host|📊42.00GB")
  144. }
  145. // A segment mixing an unlimited token with another value is kept whole,
  146. // decoration and ∞ included — only all-unlimited segments drop.
  147. mixed := expandCtx(model.Client{Email: "john"}, xray.ClientTraffic{Enable: true}, inbound)
  148. if got := expandRemarkVars("{{EMAIL}} 📊{{TRAFFIC_LEFT}}", mixed); got != "john 📊∞" {
  149. t.Errorf("mixed segment = %q, want %q", got, "john 📊∞")
  150. }
  151. }
  152. func TestExpandRemarkVars_DropEmptySegments(t *testing.T) {
  153. inbound := &model.Inbound{Remark: "host"}
  154. noComment := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
  155. if got := expandRemarkVars("{{INBOUND}}|{{COMMENT}}", noComment); got != "host" {
  156. t.Errorf("empty comment segment = %q, want %q (no trailing pipe)", got, "host")
  157. }
  158. if got := expandRemarkVars("{{INBOUND}}|📅{{EXPIRE_DATE}}", noComment); got != "host" {
  159. t.Errorf("decorated empty segment = %q, want %q", got, "host")
  160. }
  161. }
  162. func TestClientStatus(t *testing.T) {
  163. cases := []struct {
  164. name string
  165. st xray.ClientTraffic
  166. want string
  167. }{
  168. {"disabled", xray.ClientTraffic{Enable: false}, "disabled"},
  169. {"active", xray.ClientTraffic{Enable: true}, "active"},
  170. {"expired", xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "expired"}, // 1s past epoch
  171. {"depleted", xray.ClientTraffic{Enable: true, Total: gb, Up: gb}, "depleted"},
  172. }
  173. for _, c := range cases {
  174. if got := clientStatus(c.st); got != c.want {
  175. t.Errorf("%s: clientStatus = %q, want %q", c.name, got, c.want)
  176. }
  177. }
  178. }
  179. // hostRemarkService builds a SubService + inbound + client/stats for remark tests.
  180. func hostRemarkService(template string) (*SubService, *model.Inbound, model.Client) {
  181. s := &SubService{remarkTemplate: template, subscriptionBody: true}
  182. inbound := &model.Inbound{
  183. Remark: "DE",
  184. ClientStats: []xray.ClientTraffic{{
  185. Email: "[email protected]",
  186. Enable: true,
  187. Total: 100 * gb,
  188. Up: 15 * gb,
  189. Down: 5 * gb,
  190. ExpiryTime: -864_000_000, // delayed-start: deterministic 10 days
  191. }},
  192. }
  193. client := model.Client{Email: "[email protected]"}
  194. return s, inbound, client
  195. }
  196. // With no template configured, genHostRemark falls back to the inbound remark,
  197. // host and email joined by "-".
  198. func TestGenHostRemark_NoTemplate_Fallback(t *testing.T) {
  199. s, inbound, client := hostRemarkService("")
  200. if got := s.genHostRemark(inbound, client, "Relay", ""); got != "[email protected]" {
  201. t.Fatalf("genHostRemark = %q, want %q", got, "[email protected]")
  202. }
  203. if got := s.genHostRemark(inbound, client, "", ""); got != "[email protected]" {
  204. t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "[email protected]")
  205. }
  206. }
  207. // In the body the template applies: {{INBOUND}} is always the inbound's remark
  208. // and {{HOST}} the host's own remark, so the two can be shown side by side.
  209. func TestGenHostRemark_GlobalTemplate(t *testing.T) {
  210. // {{INBOUND}} resolves to the inbound remark regardless of the host remark.
  211. s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
  212. if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE | 80.00GB | 10d" {
  213. t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
  214. }
  215. // {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
  216. s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
  217. if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE|CDN|80.00GB" {
  218. t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
  219. }
  220. // {{HOST}} is the host's own remark even when the inbound has one of its own.
  221. s3, inbound3, client3 := hostRemarkService("{{HOST}}")
  222. if got := s3.genHostRemark(inbound3, client3, "CDN", ""); got != "CDN" {
  223. t.Fatalf("{{HOST}} token = %q, want CDN", got)
  224. }
  225. }
  226. // A global template also drives non-host links via genRemark; {{HOST}} = the
  227. // legacy externalProxy remark passed as extra.
  228. func TestGenRemark_GlobalTemplate(t *testing.T) {
  229. s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
  230. got := s.genRemark(inbound, "[email protected]", "", "")
  231. if got != "[email protected] | 80.00GB" {
  232. t.Fatalf("global template (non-host) = %q", got)
  233. }
  234. }
  235. func TestGenRemark_NoTemplate_AppendsEmail(t *testing.T) {
  236. s, inbound, _ := hostRemarkService("")
  237. got := s.genRemark(inbound, "[email protected]", "Relay", "")
  238. if got != "[email protected]" {
  239. t.Fatalf("genRemark = %q, want %q", got, "[email protected]")
  240. }
  241. }
  242. // The per-client info part of the template renders only on a client's first
  243. // link of the request; later links show the name-only template.
  244. func TestUsageOnFirstLinkOnly(t *testing.T) {
  245. s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
  246. first := s.genHostRemark(inbound, client, "", "")
  247. second := s.genHostRemark(inbound, client, "", "")
  248. if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
  249. t.Fatalf("first link should carry usage: %q", first)
  250. }
  251. if strings.ContainsAny(second, "📊⏳") {
  252. t.Fatalf("second link must not carry usage: %q", second)
  253. }
  254. if second != "DE" {
  255. t.Fatalf("second link = %q, want name-only %q", second, "DE")
  256. }
  257. }
  258. func TestRemarkInDisplayContext(t *testing.T) {
  259. s, inbound, client := hostRemarkService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
  260. s.subscriptionBody = false
  261. const want = "[email protected]"
  262. if got := s.genHostRemark(inbound, client, "CDN", ""); got != want {
  263. t.Fatalf("display host link = %q, want %q", got, want)
  264. }
  265. if got := s.genHostRemark(inbound, client, "", ""); got != want {
  266. t.Fatalf("display host link (no host) = %q, want %q", got, want)
  267. }
  268. if got := s.genRemark(inbound, client.Email, "", ""); got != want {
  269. t.Fatalf("display genRemark = %q, want %q", got, want)
  270. }
  271. s2, inbound2, client2 := hostRemarkService("{{INBOUND}}-{{HOST}}|📊{{TRAFFIC_LEFT}}")
  272. s2.subscriptionBody = false
  273. if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE-CDN" {
  274. t.Fatalf("display host link with HOST token = %q, want %q", got, "DE-CDN")
  275. }
  276. }
  277. func TestFilterRemarkTemplate_BodyRepeat(t *testing.T) {
  278. cases := map[string]string{
  279. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}",
  280. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",
  281. "{{INBOUND}} {{PROTOCOL}}|📊{{TRAFFIC_LEFT}}": "{{INBOUND}} {{PROTOCOL}}",
  282. "{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
  283. "{{TRAFFIC_LEFT}}|{{SECURITY}}": "{{SECURITY}}",
  284. "{{INBOUND}}|📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{INBOUND}}|{{PROTOCOL}}",
  285. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}": "{{INBOUND}}|{{EMAIL}}",
  286. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}",
  287. "{{EMAIL}} {{TRAFFIC_USED}}5h": "{{EMAIL}}",
  288. "{{PROTOCOL}} {{TRAFFIC_LEFT}}GB": "{{PROTOCOL}}",
  289. "{{EMAIL}}-{{TRAFFIC_LEFT}}D-{{HOST}}": "{{EMAIL}} {{HOST}}",
  290. "{{EMAIL}} 📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{EMAIL}} {{PROTOCOL}}",
  291. }
  292. for tmpl, want := range cases {
  293. if got := filterRemarkTemplate(tmpl, usageInfoTokens); got != want {
  294. t.Errorf("filterRemarkTemplate(%q, usage) = %q, want %q", tmpl, got, want)
  295. }
  296. }
  297. }
  298. func TestFilterRemarkTemplate_Display(t *testing.T) {
  299. cases := map[string]string{
  300. "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}": "{{INBOUND}}-{{EMAIL}}",
  301. "{{INBOUND}} {{PROTOCOL}}": "{{INBOUND}}",
  302. "{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}",
  303. "{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
  304. "{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
  305. "{{TRAFFIC_LEFT}}": "",
  306. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{HOST}}": "{{INBOUND}}|{{HOST}}",
  307. "{{EMAIL}} ⏳{{DAYS_LEFT}}D {{HOST}}": "{{EMAIL}} {{HOST}}",
  308. "{{INBOUND}} {{TRAFFIC_LEFT}} {{EMAIL}}": "{{INBOUND}} {{EMAIL}}",
  309. }
  310. for tmpl, want := range cases {
  311. if got := filterRemarkTemplate(tmpl, displayRemoveTokens); got != want {
  312. t.Errorf("filterRemarkTemplate(%q, display) = %q, want %q", tmpl, got, want)
  313. }
  314. }
  315. }
  316. func TestConnectionTokensOnEveryBodyLink(t *testing.T) {
  317. s := &SubService{
  318. remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
  319. subscriptionBody: true,
  320. usageShown: map[string]bool{},
  321. }
  322. inbound := &model.Inbound{
  323. Remark: "DE",
  324. Protocol: "vless",
  325. StreamSettings: `{"network":"ws","security":"tls"}`,
  326. ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
  327. }
  328. client := model.Client{Email: "john@x"}
  329. first := s.genTemplatedRemark(inbound, client, "", "ws")
  330. second := s.genTemplatedRemark(inbound, client, "", "ws")
  331. for _, want := range []string{"VLESS", "ws", "TLS"} {
  332. if !strings.Contains(first, want) {
  333. t.Fatalf("first body link %q missing %q", first, want)
  334. }
  335. if !strings.Contains(second, want) {
  336. t.Fatalf("repeat body link %q missing connection token %q", second, want)
  337. }
  338. }
  339. if strings.ContainsAny(second, "📊") || strings.Contains(second, "GB") {
  340. t.Fatalf("repeat body link must drop the usage block: %q", second)
  341. }
  342. }
  343. func TestConnectionTokensMixedIntoUsageSegment(t *testing.T) {
  344. s := &SubService{
  345. remarkTemplate: "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D {{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
  346. subscriptionBody: true,
  347. usageShown: map[string]bool{},
  348. }
  349. inbound := &model.Inbound{
  350. Remark: "DE",
  351. Protocol: "vless",
  352. StreamSettings: `{"network":"grpc","security":"reality"}`,
  353. ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
  354. }
  355. client := model.Client{Email: "john@x"}
  356. _ = s.genTemplatedRemark(inbound, client, "", "grpc")
  357. second := s.genTemplatedRemark(inbound, client, "", "grpc")
  358. for _, want := range []string{"VLESS", "grpc", "REALITY"} {
  359. if !strings.Contains(second, want) {
  360. t.Fatalf("repeat body link %q missing connection token %q", second, want)
  361. }
  362. }
  363. if strings.Contains(second, "GB") || strings.ContainsRune(second, '⏳') {
  364. t.Fatalf("repeat body link must drop the usage block: %q", second)
  365. }
  366. }
  367. func TestConnectionTokensDisplayContextUnchanged(t *testing.T) {
  368. s := &SubService{
  369. remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}",
  370. subscriptionBody: false,
  371. }
  372. inbound := &model.Inbound{
  373. Remark: "DE",
  374. Protocol: "vless",
  375. StreamSettings: `{"network":"ws","security":"tls"}`,
  376. ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
  377. }
  378. if got := s.genTemplatedRemark(inbound, model.Client{Email: "john@x"}, "", "ws"); got != "DE" {
  379. t.Fatalf("display remark = %q, want DE (connection after usage stripped outside the body)", got)
  380. }
  381. }
  382. func TestIdentityTokenBodyVsDisplay(t *testing.T) {
  383. const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}"
  384. inbound := &model.Inbound{
  385. Remark: "DE",
  386. Protocol: "vless",
  387. StreamSettings: `{"network":"ws","security":"tls"}`,
  388. ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
  389. }
  390. client := model.Client{Email: "john@x"}
  391. body := &SubService{remarkTemplate: tmpl, subscriptionBody: true, usageShown: map[string]bool{}}
  392. _ = body.genTemplatedRemark(inbound, client, "", "ws") // first link consumes the usage block
  393. if second := body.genTemplatedRemark(inbound, client, "", "ws"); strings.Contains(second, "john@x") {
  394. t.Fatalf("repeat body link %q must drop the identity token", second)
  395. }
  396. display := &SubService{remarkTemplate: tmpl, subscriptionBody: false}
  397. if got := display.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(got, "john@x") {
  398. t.Fatalf("display remark %q must keep the identity token", got)
  399. }
  400. }
  401. // statsForClient resolves usage from the per-request statsByEmail map when the
  402. // link's own inbound doesn't carry the client's (globally unique) traffic row —
  403. // the multi-inbound case that made {{TRAFFIC_LEFT}} show the full quota (#5443).
  404. func TestStatsForClient_CrossInboundFallback(t *testing.T) {
  405. s := &SubService{
  406. statsByEmail: map[string]xray.ClientTraffic{
  407. "[email protected]": {Email: "[email protected]", Total: 100 * gb, Up: 15 * gb, Down: 5 * gb},
  408. },
  409. }
  410. // Inbound B carries no ClientStats for john (his row is owned by inbound A).
  411. inboundB := &model.Inbound{Remark: "B"}
  412. st := s.statsForClient(inboundB, model.Client{Email: "[email protected]"})
  413. if used := st.Up + st.Down; used != 20*gb {
  414. t.Fatalf("statsForClient used = %d, want %d (cross-inbound fallback)", used, 20*gb)
  415. }
  416. if got := remarkVarValue("TRAFFIC_LEFT", remarkContext{stats: st}); got != "80.00GB" {
  417. t.Fatalf("TRAFFIC_LEFT = %q, want 80.00GB (remaining, not total)", got)
  418. }
  419. }
  420. // Two clients through the same global template get distinct, per-client remarks.
  421. func TestGenHostRemark_PerClient(t *testing.T) {
  422. s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
  423. inbound := &model.Inbound{}
  424. a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "", "")
  425. b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "", "")
  426. if a != "alice@x" || b != "bob@x" {
  427. t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
  428. }
  429. }
  430. func TestStatusEmoji(t *testing.T) {
  431. cases := []struct {
  432. stats xray.ClientTraffic
  433. want string
  434. }{
  435. {xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: gb}, "✅"},
  436. {xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: 10 * gb, Down: 1}, "🚫"},
  437. {xray.ClientTraffic{Enable: false}, "🚫"},
  438. {xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "⏳"},
  439. }
  440. for _, c := range cases {
  441. if got := statusEmoji(c.stats); got != c.want {
  442. t.Errorf("statusEmoji(%+v) = %q, want %q", c.stats, got, c.want)
  443. }
  444. }
  445. }
  446. func TestUsagePercentage(t *testing.T) {
  447. if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
  448. t.Errorf("usagePercentage 50%% = %q", got)
  449. }
  450. if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
  451. t.Errorf("usagePercentage unlimited = %q, want empty", got)
  452. }
  453. if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
  454. t.Errorf("usagePercentage 100%% = %q", got)
  455. }
  456. // Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
  457. if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
  458. t.Errorf("usagePercentage over-quota = %q, want 100.0%", got)
  459. }
  460. }
  461. func TestUsagePercentageSurvivesFragmentEncoding(t *testing.T) {
  462. remark := "node " + usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 50 * gb})
  463. link := buildLinkWithParams("vless://[email protected]:443", nil, remark)
  464. if strings.Contains(link, "%25") {
  465. t.Fatalf("encoded remark contains %%25, Happ drops such remarks: %s", link)
  466. }
  467. u, err := url.Parse(link)
  468. if err != nil {
  469. t.Fatalf("parse: %v", err)
  470. }
  471. if u.Fragment != remark {
  472. t.Fatalf("fragment = %q, want %q", u.Fragment, remark)
  473. }
  474. }
  475. func TestTimeLeftLabel(t *testing.T) {
  476. if got := timeLeftLabel(0); got != "∞" {
  477. t.Errorf("timeLeftLabel(0) = %q, want ∞", got)
  478. }
  479. // Delayed-start: negative expiry = duration in ms. 1000ms = 1 second = "0m".
  480. if got := timeLeftLabel(-1000); got != "0m" {
  481. t.Errorf("timeLeftLabel(-1000) = %q, want 0m", got)
  482. }
  483. }
  484. func TestGregorianToJalali(t *testing.T) {
  485. cases := []struct {
  486. gy, gm, gd int
  487. jy, jm, jd int
  488. }{
  489. {2024, 1, 1, 1402, 10, 11},
  490. {2000, 3, 20, 1379, 1, 1},
  491. {1979, 2, 11, 1357, 11, 22},
  492. }
  493. for _, c := range cases {
  494. jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
  495. if jy != c.jy || jm != c.jm || jd != c.jd {
  496. t.Errorf("gregorianToJalali(%d,%d,%d) = (%d,%d,%d), want (%d,%d,%d)",
  497. c.gy, c.gm, c.gd, jy, jm, jd, c.jy, c.jm, c.jd)
  498. }
  499. }
  500. }
  501. func TestJalaliExpireDateLabel(t *testing.T) {
  502. if got := jalaliExpireDateLabel(0); got != "" {
  503. t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got)
  504. }
  505. if got := jalaliExpireDateLabel(-1000); got != "" {
  506. t.Errorf("jalaliExpireDateLabel(-1000) = %q, want empty", got)
  507. }
  508. }
  509. func TestExpandNewTokensInTemplate(t *testing.T) {
  510. inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
  511. client := model.Client{Email: "[email protected]", ID: "abc-123"}
  512. stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
  513. ctx := remarkContext{
  514. client: client,
  515. stats: stats,
  516. inbound: inbound,
  517. transport: "ws",
  518. security: "reality",
  519. }
  520. cases := []struct{ tmpl, want string }{
  521. {"{{STATUS_EMOJI}}", "✅"},
  522. {"{{USAGE_PERCENTAGE}}", "50.0%"},
  523. {"{{PROTOCOL}}", "VLESS"},
  524. {"{{TRANSPORT}}", "ws"},
  525. {"{{SECURITY}}", "REALITY"},
  526. {"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
  527. }
  528. for _, c := range cases {
  529. if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
  530. t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
  531. }
  532. }
  533. }
  534. func TestInboundSecurity(t *testing.T) {
  535. cases := []struct{ stream, want string }{
  536. {`{"network":"ws","security":"tls"}`, "tls"},
  537. {`{"network":"tcp","security":"reality"}`, "reality"},
  538. {`{"network":"tcp","security":"none"}`, "none"},
  539. {`{"network":"tcp"}`, ""},
  540. {"", ""},
  541. }
  542. for _, c := range cases {
  543. if got := inboundSecurity(&model.Inbound{StreamSettings: c.stream}); got != c.want {
  544. t.Errorf("inboundSecurity(%q) = %q, want %q", c.stream, got, c.want)
  545. }
  546. }
  547. if got := inboundSecurity(nil); got != "" {
  548. t.Errorf("inboundSecurity(nil) = %q, want empty", got)
  549. }
  550. }
  551. func TestGenTemplatedRemark_SecurityFromStream(t *testing.T) {
  552. s := &SubService{remarkTemplate: "{{INBOUND}} {{SECURITY}}", subscriptionBody: true}
  553. inbound := &model.Inbound{Remark: "DE", StreamSettings: `{"network":"tcp","security":"reality"}`}
  554. if got := s.genTemplatedRemark(inbound, model.Client{Email: "a@x"}, "", "tcp"); got != "DE REALITY" {
  555. t.Fatalf("genTemplatedRemark SECURITY = %q, want %q", got, "DE REALITY")
  556. }
  557. }
  558. func TestTranslateUISingleBrackets(t *testing.T) {
  559. cases := []struct{ in, want string }{
  560. {"{EMAIL}", "{{EMAIL}}"},
  561. {"{DATA_LEFT}", "{{TRAFFIC_LEFT}}"},
  562. {"{DATA_LEFT} of {DATA_LIMIT}", "{{TRAFFIC_LEFT}} of {{TRAFFIC_TOTAL}}"},
  563. {"{STATUS_EMOJI} {INBOUND}", "{{STATUS_EMOJI}} {INBOUND}"},
  564. {"{UNKNOWN_TOKEN}", "{UNKNOWN_TOKEN}"},
  565. {"no braces", "no braces"},
  566. {"{{TRAFFIC_LEFT}}", "{{TRAFFIC_LEFT}}"},
  567. {"{username}", "{username}"},
  568. }
  569. for _, c := range cases {
  570. if got := translateUISingleBrackets(c.in); got != c.want {
  571. t.Errorf("translateUISingleBrackets(%q) = %q, want %q", c.in, got, c.want)
  572. }
  573. }
  574. }
  575. func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
  576. inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
  577. stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
  578. ctx := remarkContext{
  579. client: model.Client{Email: "[email protected]"},
  580. stats: stats,
  581. inbound: inbound,
  582. transport: "ws",
  583. security: "tls",
  584. }
  585. cases := []struct{ tmpl, want string }{
  586. {"{EMAIL}", "[email protected]"},
  587. {"{DATA_LEFT}", "50.00GB"},
  588. {"{DATA_USAGE}", "50.00GB"},
  589. {"{DATA_LIMIT}", "100.00GB"},
  590. {"{STATUS_EMOJI}", "✅"},
  591. {"{USAGE_PERCENTAGE}", "50.0%"},
  592. {"{PROTOCOL}", "VLESS"},
  593. {"{TRANSPORT}", "ws"},
  594. {"{SECURITY}", "TLS"},
  595. }
  596. for _, c := range cases {
  597. if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
  598. t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
  599. }
  600. }
  601. }
  602. func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
  603. s := &SubService{
  604. remarkTemplate: "{STATUS_EMOJI} {{INBOUND}}|📊{{TRAFFIC_LEFT}}",
  605. subscriptionBody: true,
  606. usageShown: map[string]bool{},
  607. }
  608. inbound := &model.Inbound{
  609. Remark: "DE",
  610. ClientStats: []xray.ClientTraffic{{
  611. Email: "alice@x",
  612. Enable: true,
  613. Total: 100 * gb,
  614. Up: 20 * gb,
  615. Down: 10 * gb,
  616. }},
  617. }
  618. client := model.Client{Email: "alice@x"}
  619. first := s.genTemplatedRemark(inbound, client, "", "ws")
  620. second := s.genTemplatedRemark(inbound, client, "", "ws")
  621. if !strings.Contains(first, "📊") {
  622. t.Fatalf("first link should carry usage: %q", first)
  623. }
  624. if strings.Contains(second, "📊") {
  625. t.Fatalf("second link must not carry usage: %q", second)
  626. }
  627. }
  628. func TestEmailOnFirstLinkOnly(t *testing.T) {
  629. s := &SubService{
  630. remarkTemplate: "{{INBOUND}} {{EMAIL}}|📊{{TRAFFIC_LEFT}}",
  631. subscriptionBody: true,
  632. usageShown: map[string]bool{},
  633. }
  634. inbound := &model.Inbound{
  635. Remark: "DE",
  636. ClientStats: []xray.ClientTraffic{{
  637. Email: "alice@x",
  638. Enable: true,
  639. Total: 100 * gb,
  640. }},
  641. }
  642. client := model.Client{Email: "alice@x"}
  643. first := s.genTemplatedRemark(inbound, client, "", "ws")
  644. second := s.genTemplatedRemark(inbound, client, "", "ws")
  645. if !strings.Contains(first, "alice@x") {
  646. t.Fatalf("first link should carry email: %q", first)
  647. }
  648. if strings.Contains(second, "alice@x") {
  649. t.Fatalf("second link must not carry email: %q", second)
  650. }
  651. if !strings.Contains(second, "DE") {
  652. t.Fatalf("second link should still carry the inbound name: %q", second)
  653. }
  654. }
  655. func TestIdentityOnAllLinks(t *testing.T) {
  656. const template = "{{INBOUND}}-{{EMAIL}}|{{USERNAME}}|📊{{TRAFFIC_LEFT}}|{{STATUS_EMOJI}}"
  657. inbound := &model.Inbound{
  658. Remark: "DE",
  659. ClientStats: []xray.ClientTraffic{{
  660. Email: "alice@x",
  661. Enable: true,
  662. Total: 100 * gb,
  663. Up: 20 * gb,
  664. }},
  665. }
  666. tests := []struct {
  667. name string
  668. enabled bool
  669. wantSecond string
  670. }{
  671. {name: "disabled", wantSecond: "DE"},
  672. {name: "enabled", enabled: true, wantSecond: "DE-alice@x|alice@x"},
  673. }
  674. for _, tt := range tests {
  675. t.Run(tt.name, func(t *testing.T) {
  676. s := &SubService{
  677. remarkTemplate: template,
  678. subscriptionBody: true,
  679. showIdentityOnAllLinks: tt.enabled,
  680. }
  681. client := model.Client{Email: "alice@x"}
  682. if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != "DE-alice@x|alice@x|📊80.00GB|✅" {
  683. t.Fatalf("first link = %q", got)
  684. }
  685. if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != tt.wantSecond {
  686. t.Fatalf("second link = %q, want %q", got, tt.wantSecond)
  687. }
  688. })
  689. }
  690. }
  691. func TestSharedSubIDRemark_FullInfoOncePerSubscription(t *testing.T) {
  692. const tmpl = "{{INBOUND}}-{{EMAIL}}"
  693. s := &SubService{
  694. remarkTemplate: tmpl,
  695. subscriptionBody: true,
  696. usageShown: map[string]bool{},
  697. }
  698. first := model.Client{Email: "first@example", SubID: "shared-sub"}
  699. second := model.Client{Email: "second@example", SubID: "shared-sub"}
  700. if got := s.genTemplatedRemark(&model.Inbound{Remark: "DE"}, first, "", "tcp"); got != "DE-first@example" {
  701. t.Fatalf("first credential remark = %q", got)
  702. }
  703. if got := s.genTemplatedRemark(&model.Inbound{Remark: "FI"}, second, "", "tcp"); got != "FI" {
  704. t.Fatalf("second credential with shared subId remark = %q, want identity suppressed", got)
  705. }
  706. }
  707. func TestGenTemplatedRemarkPreservesConfiguredOuterWhitespace(t *testing.T) {
  708. s := &SubService{remarkTemplate: " {{INBOUND}} ", subscriptionBody: true, usageShown: map[string]bool{}}
  709. got := s.genTemplatedRemark(&model.Inbound{Remark: "DE"}, model.Client{Email: "[email protected]"}, "", "tcp")
  710. if got != " DE " {
  711. t.Fatalf("remark = %q, want configured outer whitespace preserved", got)
  712. }
  713. }