1
0

remark_vars_test.go 28 KB

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