Files
3x-ui/internal/sub/remark_vars_test.go
T
n0ctal ad32144c42 fix(sub): use a fullwidth percent in USAGE_PERCENTAGE (#6174)
* fix(sub): use a fullwidth percent in USAGE_PERCENTAGE

A remark is placed in the share link fragment, so an ASCII percent is
percent-encoded to %25. Happ treats such a fragment as malformed, discards the
whole remark and falls back to showing the server hostname, which defeats the
point of a remark template and leaks the host into the client's server list.

Emit U+FF05 FULLWIDTH PERCENT SIGN instead. It renders the same to a reader,
never produces %25, and round-trips through url.Parse unchanged.

* test(sub): exercise production fragment encoding

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:13:15 +02:00

766 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sub
import (
"net/url"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
const gb = int64(1024 * 1024 * 1024)
// expandCtx builds a remarkContext from explicit pieces for token tests.
func expandCtx(client model.Client, stats xray.ClientTraffic, inbound *model.Inbound) remarkContext {
return remarkContext{client: client, stats: stats, inbound: inbound}
}
func TestExpandRemarkVars(t *testing.T) {
inbound := &model.Inbound{Remark: "Germany"}
client := model.Client{
Email: "john@example.com",
ID: "3f2a9c1b-aaaa-bbbb-cccc-1234567890ab",
TgID: 123456789,
SubID: "subABC",
Comment: "vip",
Reset: 30,
CreatedAt: 1_700_000_000_000,
}
// 50GB total, 8GB used (5 up + 3 down), enabled, no expiry.
stats := xray.ClientTraffic{
Enable: true,
Total: 50 * gb,
Up: 5 * gb,
Down: 3 * gb,
}
ctx := expandCtx(client, stats, inbound)
cases := []struct{ tmpl, want string }{
{"{{EMAIL}}", "john@example.com"},
{"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
{"{{HOST}}", ""}, // no host remark in ctx → empty
{"{{ID}}", client.ID},
{"{{SHORT_ID}}", "3f2a9c1b"},
{"{{TELEGRAM_ID}}", "123456789"},
{"{{SUB_ID}}", "subABC"},
{"{{COMMENT}}", "vip"},
{"{{RESET_DAYS}}", "30"},
{"{{CREATED_UNIX}}", "1700000000"},
{"{{TRAFFIC_USED}}", "8.00GB"},
{"{{TRAFFIC_LEFT}}", "42.00GB"},
{"{{TRAFFIC_TOTAL}}", "50.00GB"},
{"{{TRAFFIC_USED_BYTES}}", "8589934592"},
{"{{TRAFFIC_TOTAL_BYTES}}", "53687091200"},
{"{{UP}}", "5.00GB"},
{"{{DOWN}}", "3.00GB"},
{"{{STATUS}}", "active"},
{"{{EXPIRE_UNIX}}", "0"}, // no expiry
{"{{EXPIRE_DATE}}", ""}, // no fixed date
{"{{UNKNOWN_TOKEN}}", ""}, // unknown → empty, never literal
{"DE {{EMAIL}} ok", "DE john@example.com ok"},
{"{{EMAIL}}-{{SHORT_ID}}", "john@example.com-3f2a9c1b"},
{"no tokens here", "no tokens here"},
}
for _, c := range cases {
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
}
}
// The unlimited tokens still render ∞ at the value layer; expandRemarkVars
// is what drops an all-unlimited segment (see TestExpandRemarkVars_DropUnlimitedSegments).
if got := remarkVarValue("DAYS_LEFT", ctx); got != "∞" {
t.Errorf("remarkVarValue(DAYS_LEFT) = %q, want ∞", got)
}
}
func TestExpandRemarkVars_EdgeCases(t *testing.T) {
// Unlimited total → ∞ for human forms, 0 bytes for *_BYTES left. Checked at
// the value layer: expandRemarkVars would drop a bare ∞ segment.
unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 0, Up: gb}, nil)
if got := remarkVarValue("TRAFFIC_TOTAL", unlimited); got != "∞" {
t.Errorf("unlimited TRAFFIC_TOTAL = %q, want ∞", got)
}
if got := remarkVarValue("TRAFFIC_LEFT", unlimited); got != "∞" {
t.Errorf("unlimited TRAFFIC_LEFT = %q, want ∞", got)
}
if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", unlimited); got != "0" {
t.Errorf("unlimited TRAFFIC_LEFT_BYTES = %q, want 0", got)
}
// TgID zero → empty.
if got := expandRemarkVars("{{TELEGRAM_ID}}", unlimited); got != "" {
t.Errorf("zero TgID = %q, want empty", got)
}
// Over-quota usage clamps left to 0, not negative.
over := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: gb, Up: 2 * gb}, nil)
if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", over); got != "0" {
t.Errorf("over-quota TRAFFIC_LEFT_BYTES = %q, want 0", got)
}
// Delayed-start (negative expiry) gives deterministic whole days.
delayed := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, ExpiryTime: -864_000_000}, nil)
if got := expandRemarkVars("{{DAYS_LEFT}}", delayed); got != "10" {
t.Errorf("delayed-start DAYS_LEFT = %q, want 10", got)
}
}
// defaultRemarkTemplate mirrors the panel's shipped remark template, the one an
// inbound with no remark used to render with a leading hyphen.
const defaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
func TestExpandRemarkVars_DropsHyphenBetweenEmptyTokens(t *testing.T) {
cases := []struct {
name string
tmpl string
inbound string
email string
want string
}{
{name: "both values", tmpl: "{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "Germany-john"},
{name: "empty inbound", tmpl: "{{INBOUND}}-{{EMAIL}}", email: "john", want: "john"},
{name: "empty email", tmpl: "{{INBOUND}} - {{EMAIL}}", inbound: "Germany", want: "Germany"},
{name: "literal leading hyphen", tmpl: "-{{EMAIL}}", email: "john", want: "-john"},
{name: "literal leading hyphen before an empty token", tmpl: "-{{INBOUND}}-{{EMAIL}}", email: "john", want: "-john"},
{name: "empty var between two values keeps one separator", tmpl: "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}", email: "john", want: "john-john"},
{name: "emoji decoration before an empty token", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", email: "john", want: "🌐john"},
{name: "literal word before an empty token", tmpl: "Sub {{INBOUND}}-{{EMAIL}}", email: "john", want: "Sub john"},
{name: "decoration kept when both tokens resolve", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "🌐Germany-john"},
{name: "default template, empty inbound", tmpl: defaultRemarkTemplate, email: "john", want: "john"},
{name: "default template, empty email", tmpl: defaultRemarkTemplate, inbound: "Germany", want: "Germany"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ctx := expandCtx(model.Client{Email: tt.email}, xray.ClientTraffic{Enable: true}, &model.Inbound{Remark: tt.inbound})
if got := expandRemarkVars(tt.tmpl, ctx); got != tt.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", tt.tmpl, got, tt.want)
}
})
}
}
// An unlimited client drops the quota/expiry segments whole — decoration and the
// "|" separator included — instead of printing "📊∞|⏳∞D".
func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
inbound := &model.Inbound{Remark: "host"}
// No limit at all → only the name segment survives.
unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
if got := expandRemarkVars(tmpl, unlimited); got != "host" {
t.Errorf("fully unlimited = %q, want %q", got, "host")
}
// Limited traffic but no expiry → traffic stays, the expiry segment drops.
noExpiry := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 50 * gb, Up: 8 * gb}, inbound)
if got := expandRemarkVars(tmpl, noExpiry); got != "host|📊42.00GB" {
t.Errorf("no-expiry = %q, want %q", got, "host|📊42.00GB")
}
// A segment mixing an unlimited token with another value is kept whole,
// decoration and ∞ included — only all-unlimited segments drop.
mixed := expandCtx(model.Client{Email: "john"}, xray.ClientTraffic{Enable: true}, inbound)
if got := expandRemarkVars("{{EMAIL}} 📊{{TRAFFIC_LEFT}}", mixed); got != "john 📊∞" {
t.Errorf("mixed segment = %q, want %q", got, "john 📊∞")
}
}
func TestExpandRemarkVars_DropEmptySegments(t *testing.T) {
inbound := &model.Inbound{Remark: "host"}
noComment := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
if got := expandRemarkVars("{{INBOUND}}|{{COMMENT}}", noComment); got != "host" {
t.Errorf("empty comment segment = %q, want %q (no trailing pipe)", got, "host")
}
if got := expandRemarkVars("{{INBOUND}}|📅{{EXPIRE_DATE}}", noComment); got != "host" {
t.Errorf("decorated empty segment = %q, want %q", got, "host")
}
}
func TestClientStatus(t *testing.T) {
cases := []struct {
name string
st xray.ClientTraffic
want string
}{
{"disabled", xray.ClientTraffic{Enable: false}, "disabled"},
{"active", xray.ClientTraffic{Enable: true}, "active"},
{"expired", xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "expired"}, // 1s past epoch
{"depleted", xray.ClientTraffic{Enable: true, Total: gb, Up: gb}, "depleted"},
}
for _, c := range cases {
if got := clientStatus(c.st); got != c.want {
t.Errorf("%s: clientStatus = %q, want %q", c.name, got, c.want)
}
}
}
// hostRemarkService builds a SubService + inbound + client/stats for remark tests.
func hostRemarkService(template string) (*SubService, *model.Inbound, model.Client) {
s := &SubService{remarkTemplate: template, subscriptionBody: true}
inbound := &model.Inbound{
Remark: "DE",
ClientStats: []xray.ClientTraffic{{
Email: "john@example.com",
Enable: true,
Total: 100 * gb,
Up: 15 * gb,
Down: 5 * gb,
ExpiryTime: -864_000_000, // delayed-start: deterministic 10 days
}},
}
client := model.Client{Email: "john@example.com"}
return s, inbound, client
}
// With no template configured, genHostRemark falls back to the inbound remark,
// host and email joined by "-".
func TestGenHostRemark_NoTemplate_Fallback(t *testing.T) {
s, inbound, client := hostRemarkService("")
if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE-Relay-john@example.com" {
t.Fatalf("genHostRemark = %q, want %q", got, "DE-Relay-john@example.com")
}
if got := s.genHostRemark(inbound, client, "", ""); got != "DE-john@example.com" {
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE-john@example.com")
}
}
// In the body the template applies: {{INBOUND}} is always the inbound's remark
// and {{HOST}} the host's own remark, so the two can be shown side by side.
func TestGenHostRemark_GlobalTemplate(t *testing.T) {
// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE | 80.00GB | 10d" {
t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
}
// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE|CDN|80.00GB" {
t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
}
// {{HOST}} is the host's own remark even when the inbound has one of its own.
s3, inbound3, client3 := hostRemarkService("{{HOST}}")
if got := s3.genHostRemark(inbound3, client3, "CDN", ""); got != "CDN" {
t.Fatalf("{{HOST}} token = %q, want CDN", got)
}
}
// A global template also drives non-host links via genRemark; {{HOST}} = the
// legacy externalProxy remark passed as extra.
func TestGenRemark_GlobalTemplate(t *testing.T) {
s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
got := s.genRemark(inbound, "john@example.com", "", "")
if got != "john@example.com | 80.00GB" {
t.Fatalf("global template (non-host) = %q", got)
}
}
func TestGenRemark_NoTemplate_AppendsEmail(t *testing.T) {
s, inbound, _ := hostRemarkService("")
got := s.genRemark(inbound, "john@example.com", "Relay", "")
if got != "DE-Relay-john@example.com" {
t.Fatalf("genRemark = %q, want %q", got, "DE-Relay-john@example.com")
}
}
// The per-client info part of the template renders only on a client's first
// link of the request; later links show the name-only template.
func TestUsageOnFirstLinkOnly(t *testing.T) {
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
first := s.genHostRemark(inbound, client, "", "")
second := s.genHostRemark(inbound, client, "", "")
if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
t.Fatalf("first link should carry usage: %q", first)
}
if strings.ContainsAny(second, "📊⏳") {
t.Fatalf("second link must not carry usage: %q", second)
}
if second != "DE" {
t.Fatalf("second link = %q, want name-only %q", second, "DE")
}
}
func TestRemarkInDisplayContext(t *testing.T) {
s, inbound, client := hostRemarkService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
s.subscriptionBody = false
const want = "DE-john@example.com"
if got := s.genHostRemark(inbound, client, "CDN", ""); got != want {
t.Fatalf("display host link = %q, want %q", got, want)
}
if got := s.genHostRemark(inbound, client, "", ""); got != want {
t.Fatalf("display host link (no host) = %q, want %q", got, want)
}
if got := s.genRemark(inbound, client.Email, "", ""); got != want {
t.Fatalf("display genRemark = %q, want %q", got, want)
}
s2, inbound2, client2 := hostRemarkService("{{INBOUND}}-{{HOST}}|📊{{TRAFFIC_LEFT}}")
s2.subscriptionBody = false
if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE-CDN" {
t.Fatalf("display host link with HOST token = %q, want %q", got, "DE-CDN")
}
}
func TestFilterRemarkTemplate_BodyRepeat(t *testing.T) {
cases := map[string]string{
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",
"{{INBOUND}} {{PROTOCOL}}|📊{{TRAFFIC_LEFT}}": "{{INBOUND}} {{PROTOCOL}}",
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
"{{TRAFFIC_LEFT}}|{{SECURITY}}": "{{SECURITY}}",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{INBOUND}}|{{PROTOCOL}}",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}": "{{INBOUND}}|{{EMAIL}}",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}",
"{{EMAIL}} {{TRAFFIC_USED}}5h": "{{EMAIL}}",
"{{PROTOCOL}} {{TRAFFIC_LEFT}}GB": "{{PROTOCOL}}",
"{{EMAIL}}-{{TRAFFIC_LEFT}}D-{{HOST}}": "{{EMAIL}} {{HOST}}",
"{{EMAIL}} 📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{EMAIL}} {{PROTOCOL}}",
}
for tmpl, want := range cases {
if got := filterRemarkTemplate(tmpl, usageInfoTokens); got != want {
t.Errorf("filterRemarkTemplate(%q, usage) = %q, want %q", tmpl, got, want)
}
}
}
func TestFilterRemarkTemplate_Display(t *testing.T) {
cases := map[string]string{
"{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}": "{{INBOUND}}-{{EMAIL}}",
"{{INBOUND}} {{PROTOCOL}}": "{{INBOUND}}",
"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}",
"{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
"{{TRAFFIC_LEFT}}": "",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{HOST}}": "{{INBOUND}}|{{HOST}}",
"{{EMAIL}} ⏳{{DAYS_LEFT}}D {{HOST}}": "{{EMAIL}} {{HOST}}",
"{{INBOUND}} {{TRAFFIC_LEFT}} {{EMAIL}}": "{{INBOUND}} {{EMAIL}}",
}
for tmpl, want := range cases {
if got := filterRemarkTemplate(tmpl, displayRemoveTokens); got != want {
t.Errorf("filterRemarkTemplate(%q, display) = %q, want %q", tmpl, got, want)
}
}
}
func TestConnectionTokensOnEveryBodyLink(t *testing.T) {
s := &SubService{
remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
subscriptionBody: true,
usageShown: map[string]bool{},
}
inbound := &model.Inbound{
Remark: "DE",
Protocol: "vless",
StreamSettings: `{"network":"ws","security":"tls"}`,
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
}
client := model.Client{Email: "john@x"}
first := s.genTemplatedRemark(inbound, client, "", "ws")
second := s.genTemplatedRemark(inbound, client, "", "ws")
for _, want := range []string{"VLESS", "ws", "TLS"} {
if !strings.Contains(first, want) {
t.Fatalf("first body link %q missing %q", first, want)
}
if !strings.Contains(second, want) {
t.Fatalf("repeat body link %q missing connection token %q", second, want)
}
}
if strings.ContainsAny(second, "📊") || strings.Contains(second, "GB") {
t.Fatalf("repeat body link must drop the usage block: %q", second)
}
}
func TestConnectionTokensMixedIntoUsageSegment(t *testing.T) {
s := &SubService{
remarkTemplate: "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D {{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
subscriptionBody: true,
usageShown: map[string]bool{},
}
inbound := &model.Inbound{
Remark: "DE",
Protocol: "vless",
StreamSettings: `{"network":"grpc","security":"reality"}`,
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
}
client := model.Client{Email: "john@x"}
_ = s.genTemplatedRemark(inbound, client, "", "grpc")
second := s.genTemplatedRemark(inbound, client, "", "grpc")
for _, want := range []string{"VLESS", "grpc", "REALITY"} {
if !strings.Contains(second, want) {
t.Fatalf("repeat body link %q missing connection token %q", second, want)
}
}
if strings.Contains(second, "GB") || strings.ContainsRune(second, '⏳') {
t.Fatalf("repeat body link must drop the usage block: %q", second)
}
}
func TestConnectionTokensDisplayContextUnchanged(t *testing.T) {
s := &SubService{
remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}",
subscriptionBody: false,
}
inbound := &model.Inbound{
Remark: "DE",
Protocol: "vless",
StreamSettings: `{"network":"ws","security":"tls"}`,
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
}
if got := s.genTemplatedRemark(inbound, model.Client{Email: "john@x"}, "", "ws"); got != "DE" {
t.Fatalf("display remark = %q, want DE (connection after usage stripped outside the body)", got)
}
}
func TestIdentityTokenBodyVsDisplay(t *testing.T) {
const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}"
inbound := &model.Inbound{
Remark: "DE",
Protocol: "vless",
StreamSettings: `{"network":"ws","security":"tls"}`,
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
}
client := model.Client{Email: "john@x"}
body := &SubService{remarkTemplate: tmpl, subscriptionBody: true, usageShown: map[string]bool{}}
_ = body.genTemplatedRemark(inbound, client, "", "ws") // first link consumes the usage block
if second := body.genTemplatedRemark(inbound, client, "", "ws"); strings.Contains(second, "john@x") {
t.Fatalf("repeat body link %q must drop the identity token", second)
}
display := &SubService{remarkTemplate: tmpl, subscriptionBody: false}
if got := display.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(got, "john@x") {
t.Fatalf("display remark %q must keep the identity token", got)
}
}
// statsForClient resolves usage from the per-request statsByEmail map when the
// link's own inbound doesn't carry the client's (globally unique) traffic row —
// the multi-inbound case that made {{TRAFFIC_LEFT}} show the full quota (#5443).
func TestStatsForClient_CrossInboundFallback(t *testing.T) {
s := &SubService{
statsByEmail: map[string]xray.ClientTraffic{
"john@example.com": {Email: "john@example.com", Total: 100 * gb, Up: 15 * gb, Down: 5 * gb},
},
}
// Inbound B carries no ClientStats for john (his row is owned by inbound A).
inboundB := &model.Inbound{Remark: "B"}
st := s.statsForClient(inboundB, model.Client{Email: "john@example.com"})
if used := st.Up + st.Down; used != 20*gb {
t.Fatalf("statsForClient used = %d, want %d (cross-inbound fallback)", used, 20*gb)
}
if got := remarkVarValue("TRAFFIC_LEFT", remarkContext{stats: st}); got != "80.00GB" {
t.Fatalf("TRAFFIC_LEFT = %q, want 80.00GB (remaining, not total)", got)
}
}
// Two clients through the same global template get distinct, per-client remarks.
func TestGenHostRemark_PerClient(t *testing.T) {
s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
inbound := &model.Inbound{}
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "", "")
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "", "")
if a != "alice@x" || b != "bob@x" {
t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
}
}
func TestStatusEmoji(t *testing.T) {
cases := []struct {
stats xray.ClientTraffic
want string
}{
{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: gb}, "✅"},
{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: 10 * gb, Down: 1}, "🚫"},
{xray.ClientTraffic{Enable: false}, "🚫"},
{xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "⏳"},
}
for _, c := range cases {
if got := statusEmoji(c.stats); got != c.want {
t.Errorf("statusEmoji(%+v) = %q, want %q", c.stats, got, c.want)
}
}
}
func TestUsagePercentage(t *testing.T) {
if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0" {
t.Errorf("usagePercentage 50%% = %q", got)
}
if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
t.Errorf("usagePercentage unlimited = %q, want empty", got)
}
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0" {
t.Errorf("usagePercentage 100%% = %q", got)
}
// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0" {
t.Errorf("usagePercentage over-quota = %q, want 100.0", got)
}
}
func TestUsagePercentageSurvivesFragmentEncoding(t *testing.T) {
remark := "node " + usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 50 * gb})
link := buildLinkWithParams("vless://id@example.test:443", nil, remark)
if strings.Contains(link, "%25") {
t.Fatalf("encoded remark contains %%25, Happ drops such remarks: %s", link)
}
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse: %v", err)
}
if u.Fragment != remark {
t.Fatalf("fragment = %q, want %q", u.Fragment, remark)
}
}
func TestTimeLeftLabel(t *testing.T) {
if got := timeLeftLabel(0); got != "∞" {
t.Errorf("timeLeftLabel(0) = %q, want ∞", got)
}
// Delayed-start: negative expiry = duration in ms. 1000ms = 1 second = "0m".
if got := timeLeftLabel(-1000); got != "0m" {
t.Errorf("timeLeftLabel(-1000) = %q, want 0m", got)
}
}
func TestGregorianToJalali(t *testing.T) {
cases := []struct {
gy, gm, gd int
jy, jm, jd int
}{
{2024, 1, 1, 1402, 10, 11},
{2000, 3, 20, 1379, 1, 1},
{1979, 2, 11, 1357, 11, 22},
}
for _, c := range cases {
jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
if jy != c.jy || jm != c.jm || jd != c.jd {
t.Errorf("gregorianToJalali(%d,%d,%d) = (%d,%d,%d), want (%d,%d,%d)",
c.gy, c.gm, c.gd, jy, jm, jd, c.jy, c.jm, c.jd)
}
}
}
func TestJalaliExpireDateLabel(t *testing.T) {
if got := jalaliExpireDateLabel(0); got != "" {
t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got)
}
if got := jalaliExpireDateLabel(-1000); got != "" {
t.Errorf("jalaliExpireDateLabel(-1000) = %q, want empty", got)
}
}
func TestExpandNewTokensInTemplate(t *testing.T) {
inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
client := model.Client{Email: "alice@test.com", ID: "abc-123"}
stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
ctx := remarkContext{
client: client,
stats: stats,
inbound: inbound,
transport: "ws",
security: "reality",
}
cases := []struct{ tmpl, want string }{
{"{{STATUS_EMOJI}}", "✅"},
{"{{USAGE_PERCENTAGE}}", "50.0"},
{"{{PROTOCOL}}", "VLESS"},
{"{{TRANSPORT}}", "ws"},
{"{{SECURITY}}", "REALITY"},
{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
}
for _, c := range cases {
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
}
}
}
func TestInboundSecurity(t *testing.T) {
cases := []struct{ stream, want string }{
{`{"network":"ws","security":"tls"}`, "tls"},
{`{"network":"tcp","security":"reality"}`, "reality"},
{`{"network":"tcp","security":"none"}`, "none"},
{`{"network":"tcp"}`, ""},
{"", ""},
}
for _, c := range cases {
if got := inboundSecurity(&model.Inbound{StreamSettings: c.stream}); got != c.want {
t.Errorf("inboundSecurity(%q) = %q, want %q", c.stream, got, c.want)
}
}
if got := inboundSecurity(nil); got != "" {
t.Errorf("inboundSecurity(nil) = %q, want empty", got)
}
}
func TestGenTemplatedRemark_SecurityFromStream(t *testing.T) {
s := &SubService{remarkTemplate: "{{INBOUND}} {{SECURITY}}", subscriptionBody: true}
inbound := &model.Inbound{Remark: "DE", StreamSettings: `{"network":"tcp","security":"reality"}`}
if got := s.genTemplatedRemark(inbound, model.Client{Email: "a@x"}, "", "tcp"); got != "DE REALITY" {
t.Fatalf("genTemplatedRemark SECURITY = %q, want %q", got, "DE REALITY")
}
}
func TestTranslateUISingleBrackets(t *testing.T) {
cases := []struct{ in, want string }{
{"{EMAIL}", "{{EMAIL}}"},
{"{DATA_LEFT}", "{{TRAFFIC_LEFT}}"},
{"{DATA_LEFT} of {DATA_LIMIT}", "{{TRAFFIC_LEFT}} of {{TRAFFIC_TOTAL}}"},
{"{STATUS_EMOJI} {INBOUND}", "{{STATUS_EMOJI}} {INBOUND}"},
{"{UNKNOWN_TOKEN}", "{UNKNOWN_TOKEN}"},
{"no braces", "no braces"},
{"{{TRAFFIC_LEFT}}", "{{TRAFFIC_LEFT}}"},
{"{username}", "{username}"},
}
for _, c := range cases {
if got := translateUISingleBrackets(c.in); got != c.want {
t.Errorf("translateUISingleBrackets(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
ctx := remarkContext{
client: model.Client{Email: "alice@test.com"},
stats: stats,
inbound: inbound,
transport: "ws",
security: "tls",
}
cases := []struct{ tmpl, want string }{
{"{EMAIL}", "alice@test.com"},
{"{DATA_LEFT}", "50.00GB"},
{"{DATA_USAGE}", "50.00GB"},
{"{DATA_LIMIT}", "100.00GB"},
{"{STATUS_EMOJI}", "✅"},
{"{USAGE_PERCENTAGE}", "50.0"},
{"{PROTOCOL}", "VLESS"},
{"{TRANSPORT}", "ws"},
{"{SECURITY}", "TLS"},
}
for _, c := range cases {
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
}
}
}
func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
s := &SubService{
remarkTemplate: "{STATUS_EMOJI} {{INBOUND}}|📊{{TRAFFIC_LEFT}}",
subscriptionBody: true,
usageShown: map[string]bool{},
}
inbound := &model.Inbound{
Remark: "DE",
ClientStats: []xray.ClientTraffic{{
Email: "alice@x",
Enable: true,
Total: 100 * gb,
Up: 20 * gb,
Down: 10 * gb,
}},
}
client := model.Client{Email: "alice@x"}
first := s.genTemplatedRemark(inbound, client, "", "ws")
second := s.genTemplatedRemark(inbound, client, "", "ws")
if !strings.Contains(first, "📊") {
t.Fatalf("first link should carry usage: %q", first)
}
if strings.Contains(second, "📊") {
t.Fatalf("second link must not carry usage: %q", second)
}
}
func TestEmailOnFirstLinkOnly(t *testing.T) {
s := &SubService{
remarkTemplate: "{{INBOUND}} {{EMAIL}}|📊{{TRAFFIC_LEFT}}",
subscriptionBody: true,
usageShown: map[string]bool{},
}
inbound := &model.Inbound{
Remark: "DE",
ClientStats: []xray.ClientTraffic{{
Email: "alice@x",
Enable: true,
Total: 100 * gb,
}},
}
client := model.Client{Email: "alice@x"}
first := s.genTemplatedRemark(inbound, client, "", "ws")
second := s.genTemplatedRemark(inbound, client, "", "ws")
if !strings.Contains(first, "alice@x") {
t.Fatalf("first link should carry email: %q", first)
}
if strings.Contains(second, "alice@x") {
t.Fatalf("second link must not carry email: %q", second)
}
if !strings.Contains(second, "DE") {
t.Fatalf("second link should still carry the inbound name: %q", second)
}
}
func TestIdentityOnAllLinks(t *testing.T) {
const template = "{{INBOUND}}-{{EMAIL}}|{{USERNAME}}|📊{{TRAFFIC_LEFT}}|{{STATUS_EMOJI}}"
inbound := &model.Inbound{
Remark: "DE",
ClientStats: []xray.ClientTraffic{{
Email: "alice@x",
Enable: true,
Total: 100 * gb,
Up: 20 * gb,
}},
}
tests := []struct {
name string
enabled bool
wantSecond string
}{
{name: "disabled", wantSecond: "DE"},
{name: "enabled", enabled: true, wantSecond: "DE-alice@x|alice@x"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SubService{
remarkTemplate: template,
subscriptionBody: true,
showIdentityOnAllLinks: tt.enabled,
}
client := model.Client{Email: "alice@x"}
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != "DE-alice@x|alice@x|📊80.00GB|✅" {
t.Fatalf("first link = %q", got)
}
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != tt.wantSecond {
t.Fatalf("second link = %q, want %q", got, tt.wantSecond)
}
})
}
}
func TestSharedSubIDRemark_FullInfoOncePerSubscription(t *testing.T) {
const tmpl = "{{INBOUND}}-{{EMAIL}}"
s := &SubService{
remarkTemplate: tmpl,
subscriptionBody: true,
usageShown: map[string]bool{},
}
first := model.Client{Email: "first@example", SubID: "shared-sub"}
second := model.Client{Email: "second@example", SubID: "shared-sub"}
if got := s.genTemplatedRemark(&model.Inbound{Remark: "DE"}, first, "", "tcp"); got != "DE-first@example" {
t.Fatalf("first credential remark = %q", got)
}
if got := s.genTemplatedRemark(&model.Inbound{Remark: "FI"}, second, "", "tcp"); got != "FI" {
t.Fatalf("second credential with shared subId remark = %q, want identity suppressed", got)
}
}
func TestGenTemplatedRemarkPreservesConfiguredOuterWhitespace(t *testing.T) {
s := &SubService{remarkTemplate: " {{INBOUND}} ", subscriptionBody: true, usageShown: map[string]bool{}}
got := s.genTemplatedRemark(&model.Inbound{Remark: "DE"}, model.Client{Email: "user@example.test"}, "", "tcp")
if got != " DE " {
t.Fatalf("remark = %q, want configured outer whitespace preserved", got)
}
}