remark_vars_test.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  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. // An unlimited client drops the quota/expiry segments whole — decoration and the
  98. // "|" separator included — instead of printing "📊∞|⏳∞D".
  99. func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
  100. const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
  101. inbound := &model.Inbound{Remark: "host"}
  102. // No limit at all → only the name segment survives.
  103. unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
  104. if got := expandRemarkVars(tmpl, unlimited); got != "host" {
  105. t.Errorf("fully unlimited = %q, want %q", got, "host")
  106. }
  107. // Limited traffic but no expiry → traffic stays, the expiry segment drops.
  108. noExpiry := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 50 * gb, Up: 8 * gb}, inbound)
  109. if got := expandRemarkVars(tmpl, noExpiry); got != "host|📊42.00GB" {
  110. t.Errorf("no-expiry = %q, want %q", got, "host|📊42.00GB")
  111. }
  112. // A segment mixing an unlimited token with another value is kept whole,
  113. // decoration and ∞ included — only all-unlimited segments drop.
  114. mixed := expandCtx(model.Client{Email: "john"}, xray.ClientTraffic{Enable: true}, inbound)
  115. if got := expandRemarkVars("{{EMAIL}} 📊{{TRAFFIC_LEFT}}", mixed); got != "john 📊∞" {
  116. t.Errorf("mixed segment = %q, want %q", got, "john 📊∞")
  117. }
  118. }
  119. func TestExpandRemarkVars_DropEmptySegments(t *testing.T) {
  120. inbound := &model.Inbound{Remark: "host"}
  121. // A client with no comment: the {{COMMENT}} segment resolves to empty and
  122. // must be dropped, not left as a trailing "|".
  123. noComment := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
  124. if got := expandRemarkVars("{{INBOUND}}|{{COMMENT}}", noComment); got != "host" {
  125. t.Errorf("empty comment segment = %q, want %q (no trailing pipe)", got, "host")
  126. }
  127. // A decorated empty segment (emoji + empty token) drops whole, not leaving
  128. // a dangling emoji.
  129. if got := expandRemarkVars("{{INBOUND}}|📅{{EXPIRE_DATE}}", noComment); got != "host" {
  130. t.Errorf("decorated empty segment = %q, want %q", got, "host")
  131. }
  132. }
  133. func TestClientStatus(t *testing.T) {
  134. cases := []struct {
  135. name string
  136. st xray.ClientTraffic
  137. want string
  138. }{
  139. {"disabled", xray.ClientTraffic{Enable: false}, "disabled"},
  140. {"active", xray.ClientTraffic{Enable: true}, "active"},
  141. {"expired", xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "expired"}, // 1s past epoch
  142. {"depleted", xray.ClientTraffic{Enable: true, Total: gb, Up: gb}, "depleted"},
  143. }
  144. for _, c := range cases {
  145. if got := clientStatus(c.st); got != c.want {
  146. t.Errorf("%s: clientStatus = %q, want %q", c.name, got, c.want)
  147. }
  148. }
  149. }
  150. // hostRemarkService builds a SubService + inbound + client/stats for remark tests.
  151. func hostRemarkService(template string) (*SubService, *model.Inbound, model.Client) {
  152. s := &SubService{remarkTemplate: template, subscriptionBody: true}
  153. inbound := &model.Inbound{
  154. Remark: "DE",
  155. ClientStats: []xray.ClientTraffic{{
  156. Email: "[email protected]",
  157. Enable: true,
  158. Total: 100 * gb,
  159. Up: 15 * gb,
  160. Down: 5 * gb,
  161. ExpiryTime: -864_000_000, // delayed-start: deterministic 10 days
  162. }},
  163. }
  164. client := model.Client{Email: "[email protected]"}
  165. return s, inbound, client
  166. }
  167. // With no template configured, genHostRemark falls back to the inbound remark,
  168. // host and email joined by "-".
  169. func TestGenHostRemark_NoTemplate_Fallback(t *testing.T) {
  170. s, inbound, client := hostRemarkService("")
  171. if got := s.genHostRemark(inbound, client, "Relay", ""); got != "[email protected]" {
  172. t.Fatalf("genHostRemark = %q, want %q", got, "[email protected]")
  173. }
  174. if got := s.genHostRemark(inbound, client, "", ""); got != "[email protected]" {
  175. t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "[email protected]")
  176. }
  177. }
  178. // In the body the template applies: {{INBOUND}} is always the inbound's remark
  179. // and {{HOST}} the host's own remark, so the two can be shown side by side.
  180. func TestGenHostRemark_GlobalTemplate(t *testing.T) {
  181. // {{INBOUND}} resolves to the inbound remark regardless of the host remark.
  182. s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
  183. if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE | 80.00GB | 10d" {
  184. t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
  185. }
  186. // {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
  187. s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
  188. if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE|CDN|80.00GB" {
  189. t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
  190. }
  191. // {{HOST}} is the host's own remark even when the inbound has one of its own.
  192. s3, inbound3, client3 := hostRemarkService("{{HOST}}")
  193. if got := s3.genHostRemark(inbound3, client3, "CDN", ""); got != "CDN" {
  194. t.Fatalf("{{HOST}} token = %q, want CDN", got)
  195. }
  196. }
  197. // A global template also drives non-host links via genRemark; {{HOST}} = the
  198. // legacy externalProxy remark passed as extra.
  199. func TestGenRemark_GlobalTemplate(t *testing.T) {
  200. s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
  201. got := s.genRemark(inbound, "[email protected]", "", "")
  202. if got != "[email protected] | 80.00GB" {
  203. t.Fatalf("global template (non-host) = %q", got)
  204. }
  205. }
  206. func TestGenRemark_NoTemplate_AppendsEmail(t *testing.T) {
  207. s, inbound, _ := hostRemarkService("")
  208. got := s.genRemark(inbound, "[email protected]", "Relay", "")
  209. if got != "[email protected]" {
  210. t.Fatalf("genRemark = %q, want %q", got, "[email protected]")
  211. }
  212. }
  213. // The per-client info part of the template renders only on a client's first
  214. // link of the request; later links show the name-only template.
  215. func TestUsageOnFirstLinkOnly(t *testing.T) {
  216. s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
  217. first := s.genHostRemark(inbound, client, "", "")
  218. second := s.genHostRemark(inbound, client, "", "")
  219. if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
  220. t.Fatalf("first link should carry usage: %q", first)
  221. }
  222. if strings.ContainsAny(second, "📊⏳") {
  223. t.Fatalf("second link must not carry usage: %q", second)
  224. }
  225. if second != "DE" {
  226. t.Fatalf("second link = %q, want name-only %q", second, "DE")
  227. }
  228. }
  229. func TestRemarkInDisplayContext(t *testing.T) {
  230. s, inbound, client := hostRemarkService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
  231. s.subscriptionBody = false
  232. const want = "[email protected]"
  233. if got := s.genHostRemark(inbound, client, "CDN", ""); got != want {
  234. t.Fatalf("display host link = %q, want %q", got, want)
  235. }
  236. if got := s.genHostRemark(inbound, client, "", ""); got != want {
  237. t.Fatalf("display host link (no host) = %q, want %q", got, want)
  238. }
  239. if got := s.genRemark(inbound, client.Email, "", ""); got != want {
  240. t.Fatalf("display genRemark = %q, want %q", got, want)
  241. }
  242. s2, inbound2, client2 := hostRemarkService("{{INBOUND}}-{{HOST}}|📊{{TRAFFIC_LEFT}}")
  243. s2.subscriptionBody = false
  244. if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE-CDN" {
  245. t.Fatalf("display host link with HOST token = %q, want %q", got, "DE-CDN")
  246. }
  247. }
  248. func TestFilterRemarkTemplate_BodyRepeat(t *testing.T) {
  249. cases := map[string]string{
  250. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}",
  251. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",
  252. "{{INBOUND}} {{PROTOCOL}}|📊{{TRAFFIC_LEFT}}": "{{INBOUND}} {{PROTOCOL}}",
  253. "{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
  254. "{{TRAFFIC_LEFT}}|{{SECURITY}}": "{{SECURITY}}",
  255. "{{INBOUND}}|📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{INBOUND}}|{{PROTOCOL}}",
  256. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}": "{{INBOUND}}|{{EMAIL}}",
  257. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}",
  258. "{{EMAIL}} {{TRAFFIC_USED}}5h": "{{EMAIL}}",
  259. "{{PROTOCOL}} {{TRAFFIC_LEFT}}GB": "{{PROTOCOL}}",
  260. "{{EMAIL}}-{{TRAFFIC_LEFT}}D-{{HOST}}": "{{EMAIL}} {{HOST}}",
  261. "{{EMAIL}} 📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{EMAIL}} {{PROTOCOL}}",
  262. }
  263. for tmpl, want := range cases {
  264. if got := filterRemarkTemplate(tmpl, usageInfoTokens); got != want {
  265. t.Errorf("filterRemarkTemplate(%q, usage) = %q, want %q", tmpl, got, want)
  266. }
  267. }
  268. }
  269. func TestFilterRemarkTemplate_Display(t *testing.T) {
  270. cases := map[string]string{
  271. "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}": "{{INBOUND}}-{{EMAIL}}",
  272. "{{INBOUND}} {{PROTOCOL}}": "{{INBOUND}}",
  273. "{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}",
  274. "{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
  275. "{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
  276. "{{TRAFFIC_LEFT}}": "",
  277. "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{HOST}}": "{{INBOUND}}|{{HOST}}",
  278. "{{EMAIL}} ⏳{{DAYS_LEFT}}D {{HOST}}": "{{EMAIL}} {{HOST}}",
  279. "{{INBOUND}} {{TRAFFIC_LEFT}} {{EMAIL}}": "{{INBOUND}} {{EMAIL}}",
  280. }
  281. for tmpl, want := range cases {
  282. if got := filterRemarkTemplate(tmpl, displayRemoveTokens); got != want {
  283. t.Errorf("filterRemarkTemplate(%q, display) = %q, want %q", tmpl, got, want)
  284. }
  285. }
  286. }
  287. func TestConnectionTokensOnEveryBodyLink(t *testing.T) {
  288. s := &SubService{
  289. remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
  290. subscriptionBody: true,
  291. usageShown: map[string]bool{},
  292. }
  293. inbound := &model.Inbound{
  294. Remark: "DE",
  295. Protocol: "vless",
  296. StreamSettings: `{"network":"ws","security":"tls"}`,
  297. ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
  298. }
  299. client := model.Client{Email: "john@x"}
  300. first := s.genTemplatedRemark(inbound, client, "", "ws")
  301. second := s.genTemplatedRemark(inbound, client, "", "ws")
  302. for _, want := range []string{"VLESS", "ws", "TLS"} {
  303. if !strings.Contains(first, want) {
  304. t.Fatalf("first body link %q missing %q", first, want)
  305. }
  306. if !strings.Contains(second, want) {
  307. t.Fatalf("repeat body link %q missing connection token %q", second, want)
  308. }
  309. }
  310. if strings.ContainsAny(second, "📊") || strings.Contains(second, "GB") {
  311. t.Fatalf("repeat body link must drop the usage block: %q", second)
  312. }
  313. }
  314. func TestConnectionTokensMixedIntoUsageSegment(t *testing.T) {
  315. s := &SubService{
  316. remarkTemplate: "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D {{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
  317. subscriptionBody: true,
  318. usageShown: map[string]bool{},
  319. }
  320. inbound := &model.Inbound{
  321. Remark: "DE",
  322. Protocol: "vless",
  323. StreamSettings: `{"network":"grpc","security":"reality"}`,
  324. ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
  325. }
  326. client := model.Client{Email: "john@x"}
  327. _ = s.genTemplatedRemark(inbound, client, "", "grpc")
  328. second := s.genTemplatedRemark(inbound, client, "", "grpc")
  329. for _, want := range []string{"VLESS", "grpc", "REALITY"} {
  330. if !strings.Contains(second, want) {
  331. t.Fatalf("repeat body link %q missing connection token %q", second, want)
  332. }
  333. }
  334. if strings.Contains(second, "GB") || strings.ContainsRune(second, '⏳') {
  335. t.Fatalf("repeat body link must drop the usage block: %q", second)
  336. }
  337. }
  338. func TestConnectionTokensDisplayContextUnchanged(t *testing.T) {
  339. s := &SubService{
  340. remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}",
  341. subscriptionBody: false,
  342. }
  343. inbound := &model.Inbound{
  344. Remark: "DE",
  345. Protocol: "vless",
  346. StreamSettings: `{"network":"ws","security":"tls"}`,
  347. ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
  348. }
  349. if got := s.genTemplatedRemark(inbound, model.Client{Email: "john@x"}, "", "ws"); got != "DE" {
  350. t.Fatalf("display remark = %q, want DE (connection after usage stripped outside the body)", got)
  351. }
  352. }
  353. func TestIdentityTokenBodyVsDisplay(t *testing.T) {
  354. const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}"
  355. inbound := &model.Inbound{
  356. Remark: "DE",
  357. Protocol: "vless",
  358. StreamSettings: `{"network":"ws","security":"tls"}`,
  359. ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
  360. }
  361. client := model.Client{Email: "john@x"}
  362. body := &SubService{remarkTemplate: tmpl, subscriptionBody: true, usageShown: map[string]bool{}}
  363. _ = body.genTemplatedRemark(inbound, client, "", "ws") // first link consumes the usage block
  364. if second := body.genTemplatedRemark(inbound, client, "", "ws"); strings.Contains(second, "john@x") {
  365. t.Fatalf("repeat body link %q must drop the identity token", second)
  366. }
  367. display := &SubService{remarkTemplate: tmpl, subscriptionBody: false}
  368. if got := display.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(got, "john@x") {
  369. t.Fatalf("display remark %q must keep the identity token", got)
  370. }
  371. }
  372. // statsForClient resolves usage from the per-request statsByEmail map when the
  373. // link's own inbound doesn't carry the client's (globally unique) traffic row —
  374. // the multi-inbound case that made {{TRAFFIC_LEFT}} show the full quota (#5443).
  375. func TestStatsForClient_CrossInboundFallback(t *testing.T) {
  376. s := &SubService{
  377. statsByEmail: map[string]xray.ClientTraffic{
  378. "[email protected]": {Email: "[email protected]", Total: 100 * gb, Up: 15 * gb, Down: 5 * gb},
  379. },
  380. }
  381. // Inbound B carries no ClientStats for john (his row is owned by inbound A).
  382. inboundB := &model.Inbound{Remark: "B"}
  383. st := s.statsForClient(inboundB, model.Client{Email: "[email protected]"})
  384. if used := st.Up + st.Down; used != 20*gb {
  385. t.Fatalf("statsForClient used = %d, want %d (cross-inbound fallback)", used, 20*gb)
  386. }
  387. if got := remarkVarValue("TRAFFIC_LEFT", remarkContext{stats: st}); got != "80.00GB" {
  388. t.Fatalf("TRAFFIC_LEFT = %q, want 80.00GB (remaining, not total)", got)
  389. }
  390. }
  391. // Two clients through the same global template get distinct, per-client remarks.
  392. func TestGenHostRemark_PerClient(t *testing.T) {
  393. s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
  394. inbound := &model.Inbound{}
  395. a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "", "")
  396. b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "", "")
  397. if a != "alice@x" || b != "bob@x" {
  398. t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
  399. }
  400. }
  401. func TestStatusEmoji(t *testing.T) {
  402. cases := []struct {
  403. stats xray.ClientTraffic
  404. want string
  405. }{
  406. {xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: gb}, "✅"},
  407. {xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: 10 * gb, Down: 1}, "🚫"},
  408. {xray.ClientTraffic{Enable: false}, "🚫"},
  409. {xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "⏳"},
  410. }
  411. for _, c := range cases {
  412. if got := statusEmoji(c.stats); got != c.want {
  413. t.Errorf("statusEmoji(%+v) = %q, want %q", c.stats, got, c.want)
  414. }
  415. }
  416. }
  417. func TestUsagePercentage(t *testing.T) {
  418. if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
  419. t.Errorf("usagePercentage 50%% = %q", got)
  420. }
  421. if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
  422. t.Errorf("usagePercentage unlimited = %q, want empty", got)
  423. }
  424. if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
  425. t.Errorf("usagePercentage 100%% = %q", got)
  426. }
  427. // Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
  428. if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
  429. t.Errorf("usagePercentage over-quota = %q, want 100.0%%", got)
  430. }
  431. }
  432. func TestTimeLeftLabel(t *testing.T) {
  433. if got := timeLeftLabel(0); got != "∞" {
  434. t.Errorf("timeLeftLabel(0) = %q, want ∞", got)
  435. }
  436. // Delayed-start: negative expiry = duration in ms. 1000ms = 1 second = "0m".
  437. if got := timeLeftLabel(-1000); got != "0m" {
  438. t.Errorf("timeLeftLabel(-1000) = %q, want 0m", got)
  439. }
  440. }
  441. func TestGregorianToJalali(t *testing.T) {
  442. cases := []struct {
  443. gy, gm, gd int
  444. jy, jm, jd int
  445. }{
  446. {2024, 1, 1, 1402, 10, 11},
  447. {2000, 3, 20, 1379, 1, 1},
  448. {1979, 2, 11, 1357, 11, 22},
  449. }
  450. for _, c := range cases {
  451. jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
  452. if jy != c.jy || jm != c.jm || jd != c.jd {
  453. t.Errorf("gregorianToJalali(%d,%d,%d) = (%d,%d,%d), want (%d,%d,%d)",
  454. c.gy, c.gm, c.gd, jy, jm, jd, c.jy, c.jm, c.jd)
  455. }
  456. }
  457. }
  458. func TestJalaliExpireDateLabel(t *testing.T) {
  459. if got := jalaliExpireDateLabel(0); got != "" {
  460. t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got)
  461. }
  462. if got := jalaliExpireDateLabel(-1000); got != "" {
  463. t.Errorf("jalaliExpireDateLabel(-1000) = %q, want empty", got)
  464. }
  465. }
  466. func TestExpandNewTokensInTemplate(t *testing.T) {
  467. inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
  468. client := model.Client{Email: "[email protected]", ID: "abc-123"}
  469. stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
  470. ctx := remarkContext{
  471. client: client,
  472. stats: stats,
  473. inbound: inbound,
  474. transport: "ws",
  475. security: "reality",
  476. }
  477. cases := []struct{ tmpl, want string }{
  478. {"{{STATUS_EMOJI}}", "✅"},
  479. {"{{USAGE_PERCENTAGE}}", "50.0%"},
  480. {"{{PROTOCOL}}", "VLESS"},
  481. {"{{TRANSPORT}}", "ws"},
  482. {"{{SECURITY}}", "REALITY"},
  483. {"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
  484. }
  485. for _, c := range cases {
  486. if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
  487. t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
  488. }
  489. }
  490. }
  491. func TestInboundSecurity(t *testing.T) {
  492. cases := []struct{ stream, want string }{
  493. {`{"network":"ws","security":"tls"}`, "tls"},
  494. {`{"network":"tcp","security":"reality"}`, "reality"},
  495. {`{"network":"tcp","security":"none"}`, "none"},
  496. {`{"network":"tcp"}`, ""},
  497. {"", ""},
  498. }
  499. for _, c := range cases {
  500. if got := inboundSecurity(&model.Inbound{StreamSettings: c.stream}); got != c.want {
  501. t.Errorf("inboundSecurity(%q) = %q, want %q", c.stream, got, c.want)
  502. }
  503. }
  504. if got := inboundSecurity(nil); got != "" {
  505. t.Errorf("inboundSecurity(nil) = %q, want empty", got)
  506. }
  507. }
  508. func TestGenTemplatedRemark_SecurityFromStream(t *testing.T) {
  509. s := &SubService{remarkTemplate: "{{INBOUND}} {{SECURITY}}", subscriptionBody: true}
  510. inbound := &model.Inbound{Remark: "DE", StreamSettings: `{"network":"tcp","security":"reality"}`}
  511. if got := s.genTemplatedRemark(inbound, model.Client{Email: "a@x"}, "", "tcp"); got != "DE REALITY" {
  512. t.Fatalf("genTemplatedRemark SECURITY = %q, want %q", got, "DE REALITY")
  513. }
  514. }
  515. func TestTranslateUISingleBrackets(t *testing.T) {
  516. cases := []struct{ in, want string }{
  517. {"{EMAIL}", "{{EMAIL}}"},
  518. {"{DATA_LEFT}", "{{TRAFFIC_LEFT}}"},
  519. {"{DATA_LEFT} of {DATA_LIMIT}", "{{TRAFFIC_LEFT}} of {{TRAFFIC_TOTAL}}"},
  520. {"{STATUS_EMOJI} {INBOUND}", "{{STATUS_EMOJI}} {INBOUND}"},
  521. {"{UNKNOWN_TOKEN}", "{UNKNOWN_TOKEN}"},
  522. {"no braces", "no braces"},
  523. {"{{TRAFFIC_LEFT}}", "{{TRAFFIC_LEFT}}"},
  524. {"{username}", "{username}"},
  525. }
  526. for _, c := range cases {
  527. if got := translateUISingleBrackets(c.in); got != c.want {
  528. t.Errorf("translateUISingleBrackets(%q) = %q, want %q", c.in, got, c.want)
  529. }
  530. }
  531. }
  532. func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
  533. inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
  534. stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
  535. ctx := remarkContext{
  536. client: model.Client{Email: "[email protected]"},
  537. stats: stats,
  538. inbound: inbound,
  539. transport: "ws",
  540. security: "tls",
  541. }
  542. cases := []struct{ tmpl, want string }{
  543. {"{EMAIL}", "[email protected]"},
  544. {"{DATA_LEFT}", "50.00GB"},
  545. {"{DATA_USAGE}", "50.00GB"},
  546. {"{DATA_LIMIT}", "100.00GB"},
  547. {"{STATUS_EMOJI}", "✅"},
  548. {"{USAGE_PERCENTAGE}", "50.0%"},
  549. {"{PROTOCOL}", "VLESS"},
  550. {"{TRANSPORT}", "ws"},
  551. {"{SECURITY}", "TLS"},
  552. }
  553. for _, c := range cases {
  554. if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
  555. t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
  556. }
  557. }
  558. }
  559. func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
  560. s := &SubService{
  561. remarkTemplate: "{STATUS_EMOJI} {{INBOUND}}|📊{{TRAFFIC_LEFT}}",
  562. subscriptionBody: true,
  563. usageShown: map[string]bool{},
  564. }
  565. inbound := &model.Inbound{
  566. Remark: "DE",
  567. ClientStats: []xray.ClientTraffic{{
  568. Email: "alice@x",
  569. Enable: true,
  570. Total: 100 * gb,
  571. Up: 20 * gb,
  572. Down: 10 * gb,
  573. }},
  574. }
  575. client := model.Client{Email: "alice@x"}
  576. first := s.genTemplatedRemark(inbound, client, "", "ws")
  577. s.usageShown["alice@x"] = true
  578. second := s.genTemplatedRemark(inbound, client, "", "ws")
  579. if !strings.Contains(first, "📊") {
  580. t.Fatalf("first link should carry usage: %q", first)
  581. }
  582. if strings.Contains(second, "📊") {
  583. t.Fatalf("second link must not carry usage: %q", second)
  584. }
  585. }
  586. func TestEmailOnFirstLinkOnly(t *testing.T) {
  587. s := &SubService{
  588. remarkTemplate: "{{INBOUND}} {{EMAIL}}|📊{{TRAFFIC_LEFT}}",
  589. subscriptionBody: true,
  590. usageShown: map[string]bool{},
  591. }
  592. inbound := &model.Inbound{
  593. Remark: "DE",
  594. ClientStats: []xray.ClientTraffic{{
  595. Email: "alice@x",
  596. Enable: true,
  597. Total: 100 * gb,
  598. }},
  599. }
  600. client := model.Client{Email: "alice@x"}
  601. first := s.genTemplatedRemark(inbound, client, "", "ws")
  602. s.usageShown["alice@x"] = true
  603. second := s.genTemplatedRemark(inbound, client, "", "ws")
  604. if !strings.Contains(first, "alice@x") {
  605. t.Fatalf("first link should carry email: %q", first)
  606. }
  607. if strings.Contains(second, "alice@x") {
  608. t.Fatalf("second link must not carry email: %q", second)
  609. }
  610. if !strings.Contains(second, "DE") {
  611. t.Fatalf("second link should still carry the inbound name: %q", second)
  612. }
  613. }