Test-quality audit: fix 2 prod bugs, strengthen weak tests, add mutation/fuzz/CI tooling (#5345)

* test(audit): add gremlins/rapid/coverage tooling + AUDIT.md scaffold

* test(audit): hygiene sweep (race-clean except logger global; Finding #2) + smell inventory

* test(audit): cover untested error/edge branches (TLS proxy+pin, migration tag cleanup=Finding #1)

* test(audit): strengthen internal/sub link tests (dedup key, TLS/Reality mapping, clash well-formedness)

* test(audit): property (rapid) + fuzz tests for joinHostPort/userinfo/pin/ParseLink

* test(audit): tighten frontend subSortIndex rejection assertions + wire coverage

* ci(audit): add shuffle gate + non-blocking race job (Finding #2) + fuzz-smoke; document mutation policy

* chore(audit): gitignore frontend coverage output

* test(audit): exhaustive whole-repo pass — strengthen 5 weak/fake tests (netproxy, CSP, modal per-protocol loops, schema coercions)

* docs(contributing): add Testing section (conventions, race/shuffle, fuzz, mutation policy); drop AUDIT.md ledger

* fix(logger,migration): guard logBuffer with mutex; execute legacy tag cleanup (tx.Exec); make CI race gate blocking

* ci(mutation): add nightly scoped gremlins workflow (informational artifacts)

* test(audit): strengthen runtime tests — baseURL scheme/port bounds, isNonEmptySlice, trafficReset

* test(audit): strengthen clash tests — reality field mapping + tcp-header validation

* test(audit): runtime — egress-proxy + content-type tests; drop redundant bp=='' branch

* test(audit): strengthen link parser/helper tests (defaultPort, splitComma, base64, canonicalQuery, tls/reality/transport mapping)

* test(audit): strengthen sub/xray/common/netsafe/mtproto/config/middleware tests (kill surviving mutants)

* test(audit): raise timeout on protocol-iteration modal tests (heavy re-renders, slow on CI)

* fix(logger): GetLogs returns at most c entries (off-by-one fix; addresses PR review)

* perf(logger): snapshot logBuffer under lock so GetLogs doesn't block logging; clarify fuzz-seed docs (addresses PR review)
This commit is contained in:
Sanaei
2026-06-15 15:17:03 +02:00
committed by GitHub
parent b5872af279
commit 7605902324
37 changed files with 2580 additions and 330 deletions
+75
View File
@@ -0,0 +1,75 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// copyFile is the workhorse invoked by init()'s Windows-only DB migration
// (config.go:214), the branch guarded by the platform check on config.go:196.
// The init() guard itself cannot be re-driven from an in-process test (init runs
// once at package load, the OS check is a compile-time constant, and the old-DB
// source path is hardcoded to a system location), so these tests pin down the
// migration payload's contract instead.
func TestCopyFileCopiesContents(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.db")
dst := filepath.Join(dir, "dst.db")
want := []byte("3x-ui sqlite payload\x00\x01\x02")
if err := os.WriteFile(src, want, 0o600); err != nil {
t.Fatalf("write src: %v", err)
}
if err := copyFile(src, dst); err != nil {
t.Fatalf("copyFile returned error: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read dst: %v", err)
}
if string(got) != string(want) {
t.Errorf("dst contents = %q, want %q", got, want)
}
}
func TestCopyFileMissingSourceReturnsError(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "does-not-exist.db")
dst := filepath.Join(dir, "dst.db")
if err := copyFile(src, dst); err == nil {
t.Fatal("copyFile with missing source returned nil error, want error")
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("dst should not be created when source is missing, stat err = %v", err)
}
}
func TestCopyFileOverwritesDestination(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.db")
dst := filepath.Join(dir, "dst.db")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("write src: %v", err)
}
if err := os.WriteFile(dst, []byte("stale-and-longer"), 0o600); err != nil {
t.Fatalf("write dst: %v", err)
}
if err := copyFile(src, dst); err != nil {
t.Fatalf("copyFile returned error: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read dst: %v", err)
}
if string(got) != "new" {
t.Errorf("dst contents = %q, want %q (truncated overwrite)", got, "new")
}
}
+22 -5
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -31,8 +32,10 @@ var (
logger *logging.Logger
fileRotate *lumberjack.Logger // nil when file backend disabled
// logBuffer maintains recent log entries in memory for web UI retrieval
logBuffer []struct {
// logBuffer maintains recent log entries in memory for web UI retrieval;
// logBufferMu guards it — written from many goroutines, read by the web UI.
logBufferMu sync.Mutex
logBuffer []struct {
time string
level logging.Level
log string
@@ -193,6 +196,8 @@ func Errorf(format string, args ...any) {
// addToBuffer adds a log entry to the in-memory ring buffer for web UI retrieval.
func addToBuffer(level string, newLog string) {
t := time.Now()
logBufferMu.Lock()
defer logBufferMu.Unlock()
if len(logBuffer) >= maxLogBufferSize {
logBuffer = logBuffer[1:]
}
@@ -214,9 +219,21 @@ func GetLogs(c int, level string) []string {
var output []string
logLevel, _ := logging.LogLevel(level)
for i := len(logBuffer) - 1; i >= 0 && len(output) <= c; i-- {
if logBuffer[i].level <= logLevel {
output = append(output, fmt.Sprintf("%s %s - %s", logBuffer[i].time, logBuffer[i].level, logBuffer[i].log))
// Snapshot (copy) under the lock, then filter/format unlocked: a UI log fetch
// must not block addToBuffer — and thus all logging — for the formatting loop.
// A copy (not a reslice) is required, since addToBuffer can append in place.
logBufferMu.Lock()
snapshot := make([]struct {
time string
level logging.Level
log string
}, len(logBuffer))
copy(snapshot, logBuffer)
logBufferMu.Unlock()
for i := len(snapshot) - 1; i >= 0 && len(output) < c; i-- {
if snapshot[i].level <= logLevel {
output = append(output, fmt.Sprintf("%s %s - %s", snapshot[i].time, snapshot[i].level, snapshot[i].log))
}
}
return output
+30
View File
@@ -0,0 +1,30 @@
package logger
import (
"fmt"
"testing"
)
// TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract.
// The loop condition must cap output at c (ERROR entries are queried at "debug"
// level so the level filter passes all of them, isolating the count).
func TestGetLogs_ReturnsAtMostC(t *testing.T) {
logBufferMu.Lock()
logBuffer = nil
logBufferMu.Unlock()
for i := 0; i < 5; i++ {
addToBuffer("ERROR", fmt.Sprintf("m%d", i))
}
cases := []struct{ c, want int }{
{0, 0},
{2, 2},
{5, 5},
{10, 5}, // capped at what's available
}
for _, tc := range cases {
if got := GetLogs(tc.c, "debug"); len(got) != tc.want {
t.Errorf("GetLogs(%d) returned %d entries, want %d", tc.c, len(got), tc.want)
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package mtproto
import (
"testing"
)
// TestParseMetricLineBraceBoundary pins the contract of the brace-position
// guard in parseMetricLine (manager.go:425 -> `if end < brace`).
//
// Once a '{' is found at index `brace`, the matching '}' must appear AFTER it.
// A '}' that precedes the '{', or a '{' with no closing '}' at all
// (strings.IndexByte returns -1, which is < brace), is a malformed line and
// must yield an error rather than slicing past the brace.
func TestParseMetricLineBraceBoundary(t *testing.T) {
t.Run("closing brace before opening brace is malformed", func(t *testing.T) {
// '}' at index 8 comes before '{' at index 16: end < brace must hold,
// so this is rejected. Mutating `<` to `>`/`>=` would accept it.
_, _, _, err := parseMetricLine(`mtg_x_a}_b{direction="x"} 5`)
if err == nil {
t.Fatal("expected error for '}' appearing before '{'")
}
})
t.Run("opening brace with no closing brace is malformed", func(t *testing.T) {
// No '}' at all -> end == -1, which is < brace. Must error.
// If the guard were dropped/inverted the code would slice line[brace+1:-1]
// and panic; asserting a clean error keeps that contract.
_, _, _, err := parseMetricLine(`mtg_traffic{direction="x" 5`)
if err == nil {
t.Fatal("expected error for '{' without a closing '}'")
}
})
t.Run("well-formed braces are accepted", func(t *testing.T) {
// '{' at index 11, '}' at index 25: end > brace, so the guard must NOT
// fire and parsing must succeed. Guards against a mutant that always errors.
name, labels, val, err := parseMetricLine(`mtg_traffic{direction="up"} 42`)
if err != nil {
t.Fatalf("well-formed line should parse: %v", err)
}
if name != "mtg_traffic" {
t.Fatalf("name=%q", name)
}
if labels["direction"] != "up" {
t.Fatalf("labels=%v", labels)
}
if val != 42 {
t.Fatalf("val=%v", val)
}
})
}
// TestParseMetricLineLabelEqualsBoundary pins the contract of the '=' guard in
// the per-label loop (manager.go:430 -> `if eq < 0`).
//
// - eq < 0 (no '=' in the segment): the segment is skipped, no label added.
// - eq == 0 (segment begins with '='): the key is empty but the pair is STILL
// parsed, producing labels[""] = value. The boundary is `< 0`, not `<= 0`.
func TestParseMetricLineLabelEqualsBoundary(t *testing.T) {
t.Run("label segment without '=' is skipped, not fatal", func(t *testing.T) {
// "novalue" has no '=' (eq == -1) and must be skipped. A real key=val
// segment in the same line must still be parsed. Mutating `< 0` to `> 0`
// would take kv[:eq] with eq=-1 and panic; mutating away the skip would
// also corrupt parsing.
name, labels, val, err := parseMetricLine(`mtg_traffic{novalue,direction="down"} 9`)
if err != nil {
t.Fatalf("line with a value-less label should still parse: %v", err)
}
if name != "mtg_traffic" {
t.Fatalf("name=%q", name)
}
if _, present := labels["novalue"]; present {
t.Fatalf("value-less segment must not create a label: %v", labels)
}
if labels["direction"] != "down" {
t.Fatalf("real label must still be parsed: %v", labels)
}
if val != 9 {
t.Fatalf("val=%v", val)
}
})
t.Run("label segment beginning with '=' is parsed as empty key", func(t *testing.T) {
// "=onlyvalue": eq == 0. Since the guard is `< 0`, this is NOT skipped:
// it yields labels[""] = "onlyvalue". A mutant changing `< 0` to `<= 0`
// would skip it, losing the empty-key entry.
_, labels, _, err := parseMetricLine(`mtg_traffic{=onlyvalue} 1`)
if err != nil {
t.Fatalf("segment with empty key should still parse: %v", err)
}
v, present := labels[""]
if !present {
t.Fatalf("eq==0 segment must produce an empty-key label: %v", labels)
}
if v != "onlyvalue" {
t.Fatalf("empty-key label value=%q", v)
}
})
}
+72
View File
@@ -41,6 +41,64 @@ func TestEnsureUniqueProxyNames(t *testing.T) {
}
}
// TestBuildProxy_VLESSRealityFieldsForClash locks the reality field mapping in
// applySecurity (clash_service.go ~488): a regression that drops servername,
// public-key, short-id, or client-fingerprint would hand mihomo a broken reality
// proxy. The existing clash tests don't assert any of these.
func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
inbound := &model.Inbound{Listen: "203.0.113.1", Port: 443, Protocol: model.VLESS, Remark: "r", Settings: `{"encryption":"none"}`}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "tcp",
"security": "reality",
"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}},
"realitySettings": map[string]any{"serverName": "reality.example.com", "publicKey": "PBKvalue", "shortId": "ab12", "fingerprint": "chrome"},
}
proxy := svc.buildProxy(inbound, client, stream, "")
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid reality stream")
}
if proxy["tls"] != true {
t.Fatalf("tls = %v, want true", proxy["tls"])
}
if proxy["servername"] != "reality.example.com" {
t.Fatalf("servername = %v, want reality.example.com", proxy["servername"])
}
if proxy["client-fingerprint"] != "chrome" {
t.Fatalf("client-fingerprint = %v, want chrome", proxy["client-fingerprint"])
}
opts, _ := proxy["reality-opts"].(map[string]any)
if opts == nil {
t.Fatal("reality-opts missing")
}
if opts["public-key"] != "PBKvalue" {
t.Fatalf("public-key = %v, want PBKvalue", opts["public-key"])
}
if opts["short-id"] != "ab12" {
t.Fatalf("short-id = %v, want ab12", opts["short-id"])
}
}
// TestApplyTransport_TCPHeader pins the tcp-header validation (clash_service.go ~359):
// plain tcp and a "none" header are representable in clash; a non-none obfs header is
// not, so applyTransport must reject it (returning false drops it from the YAML).
func TestApplyTransport_TCPHeader(t *testing.T) {
svc := &SubClashService{}
if !svc.applyTransport(map[string]any{}, "tcp", map[string]any{}) {
t.Fatal("plain tcp must be buildable")
}
noneStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}}}
if !svc.applyTransport(map[string]any{}, "tcp", noneStream) {
t.Fatal("tcp + header type none must be buildable")
}
httpStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "http"}}}
if svc.applyTransport(map[string]any{}, "tcp", httpStream) {
t.Fatal("tcp + non-none (http) header is not representable in clash and must be rejected")
}
}
func TestApplyTransport_XHTTP(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
@@ -228,4 +286,18 @@ func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
if _, ok := proxy["encryption"]; ok {
t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
}
// The rest of the proxy must still be well-formed — otherwise a mutant that
// drops encryption *and* corrupts a core field passes the absence check alone.
if proxy["type"] != "vless" {
t.Fatalf("type = %v, want vless", proxy["type"])
}
if proxy["server"] != "203.0.113.1" {
t.Fatalf("server = %v, want 203.0.113.1", proxy["server"])
}
if proxy["port"] != 443 {
t.Fatalf("port = %v, want 443", proxy["port"])
}
if proxy["uuid"] != client.ID {
t.Fatalf("uuid = %v, want %v", proxy["uuid"], client.ID)
}
}
+484
View File
@@ -0,0 +1,484 @@
package sub
import (
"encoding/base64"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// initMutDB spins up a real temp SQLite DB for tests that exercise DB-backed
// query helpers, mirroring the house pattern in service_sharelink/dedup tests.
func initMutDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
}
// --- json_service.go:40 — rules are merged into routing only when non-empty ---
func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
svc := NewSubJsonService("", rules, "", nil)
routing, ok := svc.configJson["routing"].(map[string]any)
if !ok {
t.Fatalf("routing missing: %#v", svc.configJson["routing"])
}
got, _ := routing["rules"].([]any)
// default.json ships exactly 1 rule; the custom rule must be prepended.
if len(got) != 2 {
t.Fatalf("rules len = %d, want 2 (custom prepended to default)", len(got))
}
first, _ := got[0].(map[string]any)
if domains, _ := first["domain"].([]any); len(domains) != 1 || domains[0] != "geosite:ads" {
t.Fatalf("custom rule must come first, got %#v", got[0])
}
}
func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
routing, _ := svc.configJson["routing"].(map[string]any)
got, _ := routing["rules"].([]any)
if len(got) != 1 {
t.Fatalf("rules len = %d, want 1 (no custom rules → default untouched)", len(got))
}
}
// --- json_service.go:331,356,408 — mux is attached only when configured ---
func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
const mux = `{"enabled":true,"concurrency":8}`
client := model.Client{ID: "uuid-1", Password: "p4ss"}
cases := []struct {
name string
raw []byte
wantMux bool
protocol model.Protocol
}{
{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), true, model.VMESS},
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), true, model.VLESS},
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), true, model.Trojan},
{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), false, model.VMESS},
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), false, model.VLESS},
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), false, model.Trojan},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var ob map[string]any
if err := json.Unmarshal(tc.raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err)
}
m, has := ob["mux"]
if tc.wantMux {
if !has {
t.Fatalf("mux must be set when configured, outbound = %#v", ob)
}
mm, _ := m.(map[string]any)
if mm["enabled"] != true || mm["concurrency"] != float64(8) {
t.Fatalf("mux payload wrong: %#v", m)
}
} else if has {
t.Fatalf("mux must be omitted when empty, outbound = %#v", ob)
}
})
}
}
// --- json_service.go:268 — a non-empty finalMask that merges to nothing must
// not add the finalmask key (the `len(merged) > 0` guard). ---
func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// finalMask is non-empty (passes the len(fm)==0 early return) but its only
// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
// so applyGlobalFinalMask (json_service.go:268) must NOT set finalmask.
svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream["finalmask"]; ok {
t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
}
// Sanity: a finalMask that DOES merge to something still gets set, so the
// guard is the only distinguishing factor.
svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream2["finalmask"]; !ok {
t.Fatal("non-empty finalMask must be set")
}
}
// --- json_service.go:494 — an empty extra tcp slice must not clobber the base ---
func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) {
base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}}
extra := map[string]any{"tcp": []any{}} // empty → must be ignored
merged := mergeFinalMask(base, extra)
tcp, _ := merged["tcp"].([]any)
if len(tcp) != 1 {
t.Fatalf("tcp len = %d, want 1 (empty extra must not drop or append)", len(tcp))
}
if first, _ := tcp[0].(map[string]any); first["type"] != "keep" {
t.Fatalf("base tcp mask lost: %#v", tcp)
}
// Sanity: a non-empty extra DOES append, so the guard is the only thing
// distinguishing the two paths.
extra2 := map[string]any{"tcp": []any{map[string]any{"type": "add"}}}
merged2 := mergeFinalMask(base, extra2)
if tcp2, _ := merged2["tcp"].([]any); len(tcp2) != 2 {
t.Fatalf("non-empty extra must append: len = %d, want 2", len(tcp2))
}
}
// --- service.go:69-77 — configuredPublicHost priority: subDomain > webDomain > "" ---
func TestConfiguredPublicHost_Priority(t *testing.T) {
initMutDB(t)
db := database.GetDB()
set := func(key, val string) {
if err := db.Save(&model.Setting{Key: key, Value: val}).Error; err != nil {
t.Fatalf("save %s: %v", key, err)
}
}
s := &SubService{}
// Both empty → "".
if got := s.configuredPublicHost(); got != "" {
t.Fatalf("no domains configured: got %q, want empty", got)
}
// Only webDomain → webDomain wins (exercises the second branch, service.go:73).
set("webDomain", "web.example.com")
if got := s.configuredPublicHost(); got != "web.example.com" {
t.Fatalf("webDomain fallback: got %q, want web.example.com", got)
}
// subDomain set → subDomain takes precedence over webDomain (service.go:70).
set("subDomain", "sub.example.com")
if got := s.configuredPublicHost(); got != "sub.example.com" {
t.Fatalf("subDomain priority: got %q, want sub.example.com", got)
}
}
// --- service.go:248 — AggregateTrafficByEmails tracks the MAX LastOnline ---
func TestAggregateTrafficByEmails_LastOnlineIsMax(t *testing.T) {
initMutDB(t)
db := database.GetDB()
rows := []xray.ClientTraffic{
{Email: "a@x", Up: 10, Down: 20, LastOnline: 100},
{Email: "b@x", Up: 1, Down: 2, LastOnline: 500}, // the max
{Email: "c@x", Up: 3, Down: 4, LastOnline: 300},
}
for i := range rows {
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
}
s := &SubService{}
agg, lastOnline := s.AggregateTrafficByEmails([]string{"a@x", "b@x", "c@x"})
if lastOnline != 500 {
t.Fatalf("lastOnline = %d, want 500 (max across rows)", lastOnline)
}
// Up/Down must still sum so a mutant can't pass by zeroing everything.
if agg.Up != 14 || agg.Down != 26 {
t.Fatalf("agg up/down = %d/%d, want 14/26", agg.Up, agg.Down)
}
}
// --- service.go:329 — projectThroughFallbackMaster returns false for nil ---
func TestProjectThroughFallbackMaster_Nil(t *testing.T) {
s := &SubService{}
if s.projectThroughFallbackMaster(nil) {
t.Fatal("nil inbound must yield false (no projection, no DB hit)")
}
}
// --- service.go:555 — empty client flow must not emit a flow param even when allowed ---
func TestGenVlessLink_NoFlowWhenClientFlowEmpty(t *testing.T) {
// tcp+reality is a flow-allowed combo; with an empty client flow the
// len(...)>0 guard (service.go:555) must keep `flow` out of the link.
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{"serverNames":["r.example.com"],"shortIds":["ab"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}
}`
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "noflow",
Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"encryption":"none"}`,
StreamSettings: stream,
}
s := &SubService{remarkModel: "-ieo"}
if link := s.genVlessLink(inbound, "user"); strings.Contains(link, "flow=") {
t.Fatalf("empty client flow must not produce a flow param, got %q", link)
}
}
// --- service.go:906-913 — applyPathAndHostParams host source ---
func TestApplyPathAndHostParams(t *testing.T) {
// Direct host wins (service.go:908 true branch).
params := map[string]string{}
applyPathAndHostParams(map[string]any{"path": "/p", "host": "direct.example.com"}, params)
if params["path"] != "/p" {
t.Fatalf("path = %q, want /p", params["path"])
}
if params["host"] != "direct.example.com" {
t.Fatalf("direct host = %q, want direct.example.com", params["host"])
}
// No direct host → fall back to headers.Host (service.go:908 false branch).
params = map[string]string{}
applyPathAndHostParams(map[string]any{
"path": "/p",
"headers": map[string]any{"Host": "via-header.example.com"},
}, params)
if params["host"] != "via-header.example.com" {
t.Fatalf("header host fallback = %q, want via-header.example.com", params["host"])
}
// Empty-string host must NOT shadow the header fallback (len(host) > 0 guard).
params = map[string]string{}
applyPathAndHostParams(map[string]any{
"path": "/p",
"host": "",
"headers": map[string]any{"Host": "via-header.example.com"},
}, params)
if params["host"] != "via-header.example.com" {
t.Fatalf("empty host must defer to headers, got %q", params["host"])
}
}
// --- external_config.go:39,42,55,58 — getClientExternalLinksBySubId ---
func TestGetClientExternalLinksBySubId(t *testing.T) {
initMutDB(t)
db := database.GetDB()
s := &SubService{}
// No client rows for the subId → nil, no error (service.go path :42).
out, err := s.getClientExternalLinksBySubId("missing")
if err != nil {
t.Fatalf("missing subId err = %v, want nil", err)
}
if out != nil {
t.Fatalf("missing subId = %#v, want nil", out)
}
// A client with NO external-link rows → nil (the rows-empty guard :58).
bare := &model.ClientRecord{Email: "bare@x", SubID: "sub-bare", UUID: "u", Enable: true}
if err := db.Create(bare).Error; err != nil {
t.Fatalf("seed bare client: %v", err)
}
out, err = s.getClientExternalLinksBySubId("sub-bare")
if err != nil {
t.Fatalf("bare subId err = %v", err)
}
if out != nil {
t.Fatalf("client with no links = %#v, want nil", out)
}
// A client with two link rows: ordering by sort_index and email/enable
// attribution from the owning client (the loop copies rec.Email/rec.Enable).
rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://b", Remark: "second", SortIndex: 5}).Error; err != nil {
t.Fatalf("seed link b: %v", err)
}
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil {
t.Fatalf("seed link a: %v", err)
}
out, err = s.getClientExternalLinksBySubId("sub-ok")
if err != nil {
t.Fatalf("ok subId err = %v", err)
}
if len(out) != 2 {
t.Fatalf("entries = %d, want 2", len(out))
}
// sort_index ASC: the SortIndex=1 row comes first.
if out[0].Value != "trojan://a" || out[1].Value != "trojan://b" {
t.Fatalf("ordering wrong: %#v", out)
}
// Email + Enable must be copied from the owning client, not the link row
// (which carries neither field). The enabled owner → Enable true.
if out[0].Email != "owner@x" || out[0].Enable != true {
t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable)
}
// A DISABLED client must produce entries with Enable=false, proving the
// value is read from the client row (Enable has a gorm default:true, so
// flip it with a raw UPDATE that bypasses the default).
dis := &model.ClientRecord{Email: "off@x", SubID: "sub-off", UUID: "u3", Enable: true}
if err := db.Create(dis).Error; err != nil {
t.Fatalf("seed disabled client: %v", err)
}
if err := db.Model(&model.ClientRecord{}).Where("id = ?", dis.Id).Update("enable", false).Error; err != nil {
t.Fatalf("disable client: %v", err)
}
if err := db.Create(&model.ClientExternalLink{ClientId: dis.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://c", SortIndex: 1}).Error; err != nil {
t.Fatalf("seed link c: %v", err)
}
offOut, err := s.getClientExternalLinksBySubId("sub-off")
if err != nil {
t.Fatalf("off subId err = %v", err)
}
if len(offOut) != 1 {
t.Fatalf("disabled client entries = %d, want 1", len(offOut))
}
if offOut[0].Email != "off@x" || offOut[0].Enable != false {
t.Fatalf("disabled attribution wrong: email=%q enable=%v", offOut[0].Email, offOut[0].Enable)
}
}
// --- external_config.go:102 — applyRemarkToLink appends a fragment when none exists ---
func TestApplyRemarkToLink_NoFragmentAppends(t *testing.T) {
link := "trojan://pw@1.2.3.4:8443?security=tls"
out := applyRemarkToLink(link, "DE-Node")
if out != link+"#DE-Node" {
t.Fatalf("no-fragment link must get the remark appended, got %q", out)
}
}
// --- external_config.go:111 — applyVmessRemark falls back to RawURLEncoding ---
func TestApplyVmessRemark_RawURLEncodingFallback(t *testing.T) {
// The "aa?" ps forces a URL-safe char (_) in the RawURL encoding, so
// base64.StdEncoding.DecodeString fails and the RawURLEncoding fallback
// path (external_config.go:111) must take over. (ps is overwritten below,
// so its value is irrelevant to the assertions.)
payload := map[string]any{"v": "2", "ps": "aa?", "add": "1.2.3.4", "port": "443", "id": "uuid"}
b, _ := json.Marshal(payload)
link := "vmess://" + base64.RawURLEncoding.EncodeToString(b)
// Guard the premise: this link must NOT be std-decodable, else the fallback
// branch is never reached and the test is meaningless.
if _, err := base64.StdEncoding.DecodeString(padBase64Sub(strings.TrimPrefix(link, "vmess://"))); err == nil {
t.Fatal("test premise broken: link is std-base64 decodable, fallback not exercised")
}
out := applyRemarkToLink(link, "NL-Node")
if out == link {
t.Fatalf("raw-url-encoded vmess remark was not applied (fallback decode broken): %q", out)
}
// The result re-encodes with StdEncoding; decode and verify ps + credentials.
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(out, "vmess://"))
if err != nil {
t.Fatalf("decode out: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["ps"] != "NL-Node" {
t.Fatalf("ps = %v, want NL-Node", got["ps"])
}
if got["id"] != "uuid" {
t.Fatalf("credentials lost via fallback path: %#v", got)
}
}
// --- external_config.go:130 — padBase64Sub pads to a multiple of 4 ---
func TestPadBase64Sub(t *testing.T) {
cases := map[string]string{
"": "",
"a": "a===",
"ab": "ab==",
"abc": "abc=",
"abcd": "abcd",
}
for in, want := range cases {
if got := padBase64Sub(in); got != want {
t.Fatalf("padBase64Sub(%q) = %q, want %q", in, got, want)
}
if len(padBase64Sub(in))%4 != 0 {
t.Fatalf("padBase64Sub(%q) length not a multiple of 4", in)
}
}
}
// --- external_subscription.go:122 — base64 body decode strips embedded whitespace ---
func TestDecodeSubscriptionBody_StripsWhitespaceInBase64(t *testing.T) {
plain := "vless://uuid@a.com:443#one\ntrojan://pw@b.com:8443#two\n"
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
// Inject whitespace into the base64 token; tryDecodeBase64Body must strip it
// (external_subscription.go:122) so decoding still succeeds.
half := len(encoded) / 2
dirty := encoded[:half] + "\n \t" + encoded[half:]
links := decodeSubscriptionBody([]byte(dirty))
if len(links) != 2 || links[0] != "vless://uuid@a.com:443#one" || links[1] != "trojan://pw@b.com:8443#two" {
t.Fatalf("whitespace-laden base64 body decoded wrong: %#v", links)
}
}
// --- clash_service.go:123 — duplicate proxy names disambiguate as base-N ---
func TestEnsureUniqueProxyNames_SuffixSequence(t *testing.T) {
proxies := []map[string]any{
{"name": "node"},
{"name": "node"},
{"name": "node"},
}
ensureUniqueProxyNames(proxies)
if proxies[0]["name"] != "node" {
t.Fatalf("first occurrence must keep base name, got %v", proxies[0]["name"])
}
if proxies[1]["name"] != "node-2" {
t.Fatalf("second duplicate = %v, want node-2", proxies[1]["name"])
}
if proxies[2]["name"] != "node-3" {
t.Fatalf("third duplicate = %v, want node-3", proxies[2]["name"])
}
}
// --- clash_service.go:422,447 — empty transport opts must NOT add the *-opts key ---
func TestApplyTransport_EmptyOptsOmitted(t *testing.T) {
svc := &SubClashService{}
// httpupgrade with no path/host → opts empty → no http-upgrade-opts key (clash:422).
huProxy := map[string]any{}
if !svc.applyTransport(huProxy, "httpupgrade", map[string]any{"httpupgradeSettings": map[string]any{}}) {
t.Fatal("httpupgrade must still be buildable")
}
if huProxy["network"] != "httpupgrade" {
t.Fatalf("network = %v, want httpupgrade", huProxy["network"])
}
if _, ok := huProxy["http-upgrade-opts"]; ok {
t.Fatalf("empty opts must not set http-upgrade-opts: %#v", huProxy["http-upgrade-opts"])
}
// xhttp with no path/host/mode → opts empty → no xhttp-opts key (clash:447).
xhProxy := map[string]any{}
if !svc.applyTransport(xhProxy, "xhttp", map[string]any{"xhttpSettings": map[string]any{}}) {
t.Fatal("xhttp must still be buildable")
}
if xhProxy["network"] != "xhttp" {
t.Fatalf("network = %v, want xhttp", xhProxy["network"])
}
if _, ok := xhProxy["xhttp-opts"]; ok {
t.Fatalf("empty opts must not set xhttp-opts: %#v", xhProxy["xhttp-opts"])
}
}
+32
View File
@@ -3,6 +3,7 @@ package sub
import (
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -62,4 +63,35 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
if len(emails) != 1 {
t.Fatalf("emails = %d, want 1, got %v", len(emails), emails)
}
// Identity, not just count: the single surviving link must be for this client.
if !strings.Contains(links[0], uuid) {
t.Fatalf("surviving link must carry the client uuid %q, got %q", uuid, links[0])
}
}
// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count:
// the two entries differ only by email case, so dropping strings.ToLower (or keying on
// another field) yields two clients. The byte-identical dupes above can't catch that.
func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
const subId = "s1"
const uuid = "11111111-2222-4333-8444-555555555555"
ib := &model.Inbound{
Protocol: model.VLESS,
Settings: `{"clients":[
{"id":"` + uuid + `","email":"Dup@Example.com","subId":"` + subId + `","enable":true},
{"id":"` + uuid + `","email":"dup@example.com","subId":"` + subId + `","enable":true}
]}`,
}
s := &SubService{}
got := s.matchingClients(ib, subId)
if len(got) != 1 {
t.Fatalf("case-differing duplicate emails must dedup to 1 client, got %d", len(got))
}
if got[0].Email != "Dup@Example.com" {
t.Fatalf("first occurrence must be kept, got %q", got[0].Email)
}
// A wrong subId must still be excluded (guards the subId filter at service.go:127).
if other := s.matchingClients(ib, "nope"); len(other) != 0 {
t.Fatalf("non-matching subId must yield 0 clients, got %d", len(other))
}
}
+89
View File
@@ -0,0 +1,89 @@
package sub
import (
"net"
"net/url"
"strconv"
"strings"
"testing"
"pgregory.net/rapid"
)
// TestProp_JoinHostPort_Bracketing asserts the RFC-3986 authority contract for any
// host/port: SplitHostPort must recover the (un-bracketed) host and the exact port,
// and an IPv6 literal is bracketed exactly once regardless of input brackets.
func TestProp_JoinHostPort_Bracketing(t *testing.T) {
hosts := []string{
"1.2.3.4", "example.com", "sub.host.test",
"2001:db8::1", "[2001:db8::1]", "::1", "[::1]", "fe80::1%eth0",
}
rapid.Check(t, func(t *rapid.T) {
host := rapid.SampledFrom(hosts).Draw(t, "host")
port := rapid.IntRange(0, 65535).Draw(t, "port")
out := joinHostPort(host, port)
gotHost, gotPort, err := net.SplitHostPort(out)
if err != nil {
t.Fatalf("SplitHostPort(%q) failed: %v", out, err)
}
wantHost := strings.Trim(host, "[]")
if gotHost != wantHost {
t.Fatalf("host round-trip: joinHostPort(%q,%d)=%q -> host %q, want %q", host, port, out, gotHost, wantHost)
}
if gotPort != strconv.Itoa(port) {
t.Fatalf("port round-trip: got %q, want %d (out=%q)", gotPort, port, out)
}
// An IPv6 literal (contains a colon in the host) must be bracketed once.
if strings.Contains(wantHost, ":") {
if strings.Count(out, "[") != 1 || strings.Count(out, "]") != 1 {
t.Fatalf("IPv6 host not bracketed exactly once: %q", out)
}
}
})
}
// TestProp_EncodeUserinfo_RoundTrip asserts encodeUserinfo produces a userinfo token
// that net/url parses back to the original password for ANY input — the contract that
// trojan/ss links rely on. A field-mapping mutant that mangles escaping breaks this.
func TestProp_EncodeUserinfo_RoundTrip(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
pw := rapid.String().Draw(t, "pw")
raw := "trojan://" + encodeUserinfo(pw) + "@example.com:443"
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("url.Parse(%q) failed for pw=%q: %v", raw, pw, err)
}
if got := u.User.Username(); got != pw {
t.Fatalf("userinfo round-trip mismatch: pw=%q got=%q", pw, got)
}
})
}
// TestProp_SplitLinkLines_Invariants asserts splitLinkLines never emits empty or
// untrimmed lines, and that re-splitting its own joined output is a fixed point.
func TestProp_SplitLinkLines_Invariants(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
raw := rapid.String().Draw(t, "raw")
out := splitLinkLines(raw)
for i, line := range out {
if line == "" {
t.Fatalf("splitLinkLines emitted an empty line at %d for %q", i, raw)
}
if line != strings.TrimSpace(line) {
t.Fatalf("splitLinkLines emitted an untrimmed line %q", line)
}
}
rejoined := splitLinkLines(strings.Join(out, "\n"))
if len(rejoined) != len(out) {
t.Fatalf("not a fixed point: %d -> %d lines", len(out), len(rejoined))
}
for i := range out {
if rejoined[i] != out[i] {
t.Fatalf("fixed-point mismatch at %d: %q vs %q", i, out[i], rejoined[i])
}
}
})
}
+89
View File
@@ -0,0 +1,89 @@
package sub
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// shareLinkInbound builds a VLESS inbound with one client and the given stream
// settings, mirroring flowTestInbound but without forcing a flow.
func shareLinkInbound(streamSettings string) *model.Inbound {
return &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "sharelink",
Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"decryption":"none","encryption":"none"}`,
StreamSettings: streamSettings,
}
}
// TestGenVlessLink_TLSParamsMapped locks every field that applyShareTLSParams
// (service.go:1029) writes into a TLS share link. Without these assertions a mutant
// that drops `sni`, swaps a key, or skips `pcs`/`alpn`/`fp` survives the whole suite —
// the existing flow tests only check `flow=`.
func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"tls",
"tcpSettings":{"header":{"type":"none"}},
"tlsSettings":{
"serverName":"sni.example.com",
"alpn":["h2","http/1.1"],
"settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["YWJj"]}
}
}`
s := &SubService{remarkModel: "-ieo"}
link := s.genVlessLink(shareLinkInbound(stream), "user")
// url.Values.Encode() percent-encodes values: "," -> %2C, "/" -> %2F.
wants := []string{
"security=tls",
"sni=sni.example.com",
"fp=chrome",
"alpn=h2%2Chttp%2F1.1",
"pcs=YWJj",
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("TLS link missing %q\n got: %s", w, link)
}
}
}
// TestGenVlessLink_RealityParamsMapped locks the reality field mapping
// (applyShareRealityParams, service.go:1147). serverNames/shortIds are single-element
// so random.Num is deterministic (index 0); spx is random so it is asserted by prefix.
// Distinct pbk/sid values catch a pbk<->sid swap mutant.
func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`
s := &SubService{remarkModel: "-ieo"}
link := s.genVlessLink(shareLinkInbound(stream), "user")
wants := []string{
"security=reality",
"sni=reality.example.com",
"pbk=PBKvalue",
"sid=ab12cd",
"fp=firefox",
"spx=%2F", // "/" + random.Seq(15), percent-encoded leading slash
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("reality link missing %q\n got: %s", w, link)
}
}
// A pbk<->sid swap must not silently pass: pbk must not carry the shortId.
if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") {
t.Fatalf("reality pbk/sid mapping crossed: %s", link)
}
}
@@ -0,0 +1,60 @@
package common
import "testing"
// TestFormatTraffic_UnitBoundaries pins the exact switch point in the loop
// condition `size >= 1024`: a unit must roll over at exactly 1024 (not 1023,
// not 1025), and a value one byte short must stay in the lower unit. This kills
// CONDITIONALS_BOUNDARY (>= -> >) and ARITHMETIC_BASE on the 1024 comparison.
func TestFormatTraffic_UnitBoundaries(t *testing.T) {
cases := []struct {
name string
bytes int64
want string
}{
// Just below the first boundary: must NOT roll over to KB.
{"one_below_kb", 1023, "1023.00B"},
// Exactly at the boundary: must roll over to KB.
{"exactly_kb", 1024, "1.00KB"},
// Just above: stays in KB.
{"one_above_kb", 1025, "1.00KB"},
// Just below the MB boundary: stays in KB (proves division divisor 1024).
{"one_below_mb", 1024*1024 - 1, "1024.00KB"},
// Exactly at the MB boundary: rolls over to MB.
{"exactly_mb", 1024 * 1024, "1.00MB"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := FormatTraffic(c.bytes); got != c.want {
t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
}
})
}
}
// TestFormatTraffic_ClampsAtPB pins the upper bound guard
// `unitIndex < len(units)-1`: huge values must clamp at PB instead of indexing
// past the units slice. A mutated bound (< -> <= via CONDITIONALS_BOUNDARY, or
// len(units)-1 -> len(units)+1 via INVERT_NEGATIVES/ARITHMETIC_BASE) would run
// one extra iteration and panic with index-out-of-range, so the assertion that
// these return a normal "PB" string kills those mutants.
func TestFormatTraffic_ClampsAtPB(t *testing.T) {
const pb = int64(1024 * 1024 * 1024 * 1024 * 1024)
cases := []struct {
name string
bytes int64
want string
}{
// Stays at PB even though size is still >= 1024 at the PB level.
{"1024_pb", 1024 * pb, "1024.00PB"},
// Max int64 must not overflow the units slice.
{"max_int64", 9223372036854775807, "8192.00PB"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := FormatTraffic(c.bytes); got != c.want {
t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
}
})
}
}
+28
View File
@@ -0,0 +1,28 @@
package link
import "testing"
// FuzzParseLink asserts the parser never panics and upholds its (result, error) contract
// — exactly one non-nil. It base64-decodes and type-asserts attacker-controllable JSON,
// the classic panic source.
func FuzzParseLink(f *testing.F) {
seeds := []string{
"",
"not-a-link",
"vmess://eyJ2IjoiMiIsInBzIjoidCIsImFkZCI6ImEuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwibmV0IjoidGNwIn0=",
"vless://11111111-2222-4333-8444-555555555555@a.com:443?type=tcp&security=none#x",
"trojan://pass@a.com:443?security=tls#x",
"ss://YWVzLTI1Ni1nY206cGFzcw==@a.com:8388#x",
"hysteria2://pass@a.com:443?sni=a.com#x",
"wireguard://cGsdkey@a.com:51820?publickey=pub#x",
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, s string) {
res, err := ParseLink(s)
if (res == nil) == (err == nil) {
t.Fatalf("ParseLink(%q): exactly one of (result, error) must be non-nil; got res=%v err=%v", s, res, err)
}
})
}
+201
View File
@@ -0,0 +1,201 @@
package link
import (
"encoding/base64"
"net/url"
"reflect"
"testing"
)
func TestDefaultPort(t *testing.T) {
cases := []struct {
in string
def int
want int
}{
{"", 443, 443},
{"8080", 443, 8080},
{"0", 443, 443}, // non-positive falls back
{"-1", 443, 443}, // negative falls back
{"abc", 443, 443}, // unparseable falls back
{"65535", 443, 65535},
}
for _, c := range cases {
if got := defaultPort(c.in, c.def); got != c.want {
t.Errorf("defaultPort(%q,%d) = %d, want %d", c.in, c.def, got, c.want)
}
}
}
func TestFirstNonEmptyAndParam(t *testing.T) {
if got := firstNonEmpty("a", "b"); got != "a" {
t.Errorf("firstNonEmpty(a,b) = %q, want a", got)
}
if got := firstNonEmpty("", "b"); got != "b" {
t.Errorf("firstNonEmpty(,b) = %q, want b", got)
}
p := url.Values{"x": {""}, "y": {"hit"}, "z": {"z"}}
if got := firstParam(p, "x", "y", "z"); got != "hit" {
t.Errorf("firstParam = %q, want hit (first non-empty)", got)
}
if got := firstParam(p, "x"); got != "" {
t.Errorf("firstParam(only empty) = %q, want empty", got)
}
}
func TestSplitComma(t *testing.T) {
if got := splitComma(""); got != nil {
t.Errorf("splitComma(empty) = %v, want nil", got)
}
if got := splitComma("a, ,b ,, c"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
t.Errorf("splitComma trim/skip = %v, want [a b c]", got)
}
if got := splitCommaOrDefault("", []string{"d"}); !reflect.DeepEqual(got, []string{"d"}) {
t.Errorf("splitCommaOrDefault(empty) = %v, want [d]", got)
}
if got := splitCommaOrDefault("x,y", []string{"d"}); !reflect.DeepEqual(got, []string{"x", "y"}) {
t.Errorf("splitCommaOrDefault(x,y) = %v, want [x y]", got)
}
}
func TestPadAndBase64DecodeFlexible(t *testing.T) {
if got := padBase64("abc"); got != "abc=" {
t.Errorf("padBase64(abc) = %q, want abc=", got)
}
if got := padBase64("abcd"); got != "abcd" {
t.Errorf("padBase64(abcd) = %q, want unchanged", got)
}
std := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secret"))
if got, err := base64DecodeFlexible(std); err != nil || got != "aes-256-gcm:secret" {
t.Errorf("base64DecodeFlexible(std) = (%q,%v), want (aes-256-gcm:secret,nil)", got, err)
}
rawURL := base64.RawURLEncoding.EncodeToString([]byte("m:p"))
if got, err := base64DecodeFlexible(rawURL); err != nil || got != "m:p" {
t.Errorf("base64DecodeFlexible(rawurl) = (%q,%v), want (m:p,nil)", got, err)
}
if _, err := base64DecodeFlexible("!!!not!!!"); err == nil {
t.Error("base64DecodeFlexible(garbage) should error")
}
}
func TestDecodeHash(t *testing.T) {
if got := decodeHash(""); got != "" {
t.Errorf("decodeHash(empty) = %q, want empty", got)
}
if got := decodeHash("a%20b"); got != "a b" {
t.Errorf("decodeHash(a%%20b) = %q, want 'a b'", got)
}
if got := decodeHash("plain"); got != "plain" {
t.Errorf("decodeHash(plain) = %q, want plain", got)
}
}
func TestCanonicalQuery_SortsKeys(t *testing.T) {
// unsorted input must come out key-sorted for a stable identity
got := canonicalQuery(url.Values{"c": {"3"}, "a": {"1"}, "b": {"2"}})
if got != "a=1&b=2&c=3" {
t.Fatalf("canonicalQuery = %q, want a=1&b=2&c=3", got)
}
}
// stream navigates res.Outbound["streamSettings"][key] as a map.
func streamSub(t *testing.T, res *ParseResult, key string) map[string]any {
t.Helper()
ss, _ := res.Outbound["streamSettings"].(map[string]any)
m, ok := ss[key].(map[string]any)
if !ok {
t.Fatalf("streamSettings.%s missing/not a map: %#v", key, ss)
}
return m
}
func TestParse_RealitySecurityMapped(t *testing.T) {
res, err := ParseLink("vless://uuid@h.com:443?type=tcp&security=reality&pbk=PBK&sid=SID&sni=SNI&fp=firefox&spx=%2Fspx&pqv=PQV")
if err != nil {
t.Fatalf("parse: %v", err)
}
re := streamSub(t, res, "realitySettings")
for k, want := range map[string]string{"publicKey": "PBK", "shortId": "SID", "serverName": "SNI", "fingerprint": "firefox", "spiderX": "/spx", "mldsa65Verify": "PQV"} {
if re[k] != want {
t.Errorf("realitySettings[%q] = %v, want %q", k, re[k], want)
}
}
}
func TestParse_TLSSecurityMapped(t *testing.T) {
res, err := ParseLink("trojan://pw@h.com:443?type=tcp&security=tls&sni=SNI&fp=chrome&alpn=h2,http/1.1&ech=ECH&pcs=PCS")
if err != nil {
t.Fatalf("parse: %v", err)
}
tls := streamSub(t, res, "tlsSettings")
if tls["serverName"] != "SNI" || tls["fingerprint"] != "chrome" || tls["echConfigList"] != "ECH" || tls["pinnedPeerCertSha256"] != "PCS" {
t.Errorf("tlsSettings fields = %#v", tls)
}
if alpn, _ := tls["alpn"].([]string); !reflect.DeepEqual(alpn, []string{"h2", "http/1.1"}) {
t.Errorf("alpn = %#v, want [h2 http/1.1]", tls["alpn"])
}
}
func TestParse_WSAndGRPCTransport(t *testing.T) {
ws, err := ParseLink("vless://uuid@h.com:443?type=ws&host=H&path=%2Fwspath")
if err != nil {
t.Fatalf("parse ws: %v", err)
}
wss := streamSub(t, ws, "wsSettings")
if wss["host"] != "H" || wss["path"] != "/wspath" {
t.Errorf("wsSettings = %#v, want host=H path=/wspath", wss)
}
grpc, err := ParseLink("vless://uuid@h.com:443?type=grpc&serviceName=svc&authority=auth&mode=multi")
if err != nil {
t.Fatalf("parse grpc: %v", err)
}
gs := streamSub(t, grpc, "grpcSettings")
if gs["serviceName"] != "svc" || gs["authority"] != "auth" || gs["multiMode"] != true {
t.Errorf("grpcSettings = %#v, want serviceName=svc authority=auth multiMode=true", gs)
}
}
func TestParse_TCPHTTPHeader(t *testing.T) {
res, err := ParseLink("vless://uuid@h.com:443?type=tcp&headerType=http&host=ex.com&path=%2F")
if err != nil {
t.Fatalf("parse: %v", err)
}
tcp := streamSub(t, res, "tcpSettings")
header, _ := tcp["header"].(map[string]any)
if header["type"] != "http" {
t.Errorf("tcp header type = %v, want http", header["type"])
}
}
func TestParseVless_CoreFields(t *testing.T) {
res, err := ParseLink("vless://the-uuid@9.9.9.9:8443?type=tcp&security=none&flow=xtls-rprx-vision#tag1")
if err != nil {
t.Fatalf("parse: %v", err)
}
st, _ := res.Outbound["settings"].(map[string]any)
if st["address"] != "9.9.9.9" || st["port"] != 8443 || st["id"] != "the-uuid" || st["flow"] != "xtls-rprx-vision" {
t.Errorf("vless settings = %#v", st)
}
}
func TestParseTrojanAndSS_CoreFields(t *testing.T) {
tr, err := ParseLink("trojan://secret@t.com:443?type=tcp&security=tls#tj")
if err != nil {
t.Fatalf("parse trojan: %v", err)
}
srv := tr.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
if srv["address"] != "t.com" || srv["port"] != 443 || srv["password"] != "secret" {
t.Errorf("trojan server = %#v", srv)
}
ssLink := "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:sspass")) + "@s.com:8388#ss1"
ss, err := ParseLink(ssLink)
if err != nil {
t.Fatalf("parse ss: %v", err)
}
ssrv := ss.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
if ssrv["address"] != "s.com" || ssrv["port"] != 8388 || ssrv["password"] != "sspass" || ssrv["method"] != "aes-256-gcm" {
t.Errorf("ss server = %#v", ssrv)
}
}
+32 -6
View File
@@ -2,6 +2,8 @@ package netproxy
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
)
@@ -22,6 +24,10 @@ func TestNewHTTPClient(t *testing.T) {
{name: "unsupported scheme errors", proxyURL: "ftp://127.0.0.1:21", wantErr: true},
}
// baseTransport clones http.DefaultTransport, whose Proxy and DialContext are already
// non-nil — so "!= nil" can't prove our proxy/dialer was applied. Check the real values.
defaultDialPtr := reflect.ValueOf(http.DefaultTransport.(*http.Transport).DialContext).Pointer()
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, err := NewHTTPClient(tc.proxyURL, 5*time.Second)
@@ -37,16 +43,36 @@ func TestNewHTTPClient(t *testing.T) {
if client.Timeout != 5*time.Second {
t.Errorf("timeout = %v, want 5s", client.Timeout)
}
// Empty proxyURL → a plain direct client with no custom transport.
if tc.proxyURL == "" {
if client.Transport != nil {
t.Errorf("empty proxy must yield a direct client (nil Transport), got %T", client.Transport)
}
return
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport is %T, want *http.Transport", client.Transport)
}
if tc.wantProxy {
transport, ok := client.Transport.(*http.Transport)
if !ok || transport.Proxy == nil {
t.Errorf("expected transport with Proxy set for %q", tc.proxyURL)
// Prove the CONFIGURED proxy is applied: transport.Proxy(req) must
// return our URL, not the cloned default's ProxyFromEnvironment.
req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
u, perr := transport.Proxy(req)
if perr != nil {
t.Fatalf("transport.Proxy returned error: %v", perr)
}
if u == nil || u.String() != tc.proxyURL {
t.Errorf("transport.Proxy(req) = %v, want %q (configured proxy not applied)", u, tc.proxyURL)
}
}
if tc.wantDial {
transport, ok := client.Transport.(*http.Transport)
if !ok || transport.DialContext == nil {
t.Errorf("expected transport with DialContext set for %q", tc.proxyURL)
if transport.DialContext == nil {
t.Fatal("DialContext is nil")
}
// Must be the socks5 dialer, not the cloned default DialContext.
if reflect.ValueOf(transport.DialContext).Pointer() == defaultDialPtr {
t.Error("DialContext is still the default; socks5 dialer was not applied")
}
}
})
@@ -0,0 +1,102 @@
package netsafe
import (
"context"
"strings"
"testing"
)
// TestSSRFGuardedDialContext_LiteralIPSkipsResolver pins the netsafe.go:37
// decision (`if ip := net.ParseIP(host); ip != nil`). The string "fe80::1%eth0"
// is rejected by net.ParseIP (returns nil) but accepted by the resolver, which
// yields the link-local address fe80::1. With the branch intact, ParseIP returns
// nil so the host falls through to LookupIPAddr, resolves to fe80::1, and is
// blocked by IsBlockedIP -> the error mentions the resolved blocked address.
// If the condition is flipped to `ip == nil`, the nil-IP literal path is taken
// instead: ips = [{IP: nil}], IsBlockedIP(nil) is false, the guard never fires
// and the error would never say "blocked private/internal address fe80::1".
func TestSSRFGuardedDialContext_LiteralIPSkipsResolver(t *testing.T) {
_, err := SSRFGuardedDialContext(context.Background(), "tcp", "[fe80::1%eth0]:80")
if err == nil {
t.Fatal("expected error for link-local host with zone suffix")
}
if !strings.Contains(err.Error(), "blocked private/internal address fe80::1") {
t.Fatalf("expected guard to block resolved link-local fe80::1, got: %v", err)
}
}
// TestSSRFGuardedDialContext_LiteralPrivateIPv6Blocked complements the above by
// confirming that a valid IP literal (parsed by the line 37 branch) is still run
// through IsBlockedIP and rejected with the literal in the message.
func TestSSRFGuardedDialContext_LiteralPrivateIPv6Blocked(t *testing.T) {
_, err := SSRFGuardedDialContext(context.Background(), "tcp", "[::1]:80")
if err == nil {
t.Fatal("expected dial to ::1 to be blocked")
}
if !strings.Contains(err.Error(), "blocked private/internal address ::1") {
t.Fatalf("expected '::1' literal in blocked error, got: %v", err)
}
}
// TestNormalizeHost_LengthBoundary pins the netsafe.go:76 length check
// (`len(addr) > 253`). A valid-pattern hostname of exactly 253 chars must be
// accepted (kills `>` -> `>=` / off-by-one mutations of the bound), while the
// same hostname at 254 chars must be rejected.
func TestNormalizeHost_LengthBoundary(t *testing.T) {
label := strings.Repeat("a", 61)
base := label + "." + label + "." + label + "." // 186 chars, valid pattern
h253 := base + strings.Repeat("a", 253-len(base))
if len(h253) != 253 {
t.Fatalf("test setup: expected 253-char host, got %d", len(h253))
}
h254 := h253 + "a"
got, err := NormalizeHost(h253)
if err != nil {
t.Fatalf("NormalizeHost(253-char valid host) returned error: %v", err)
}
if got != h253 {
t.Fatalf("NormalizeHost(253-char host) = %q, want unchanged input", got)
}
if _, err := NormalizeHost(h254); err == nil {
t.Fatal("NormalizeHost(254-char host) expected error, got nil")
}
}
// TestNormalizeHost_PatternClauseIndependentOfLength pins the OR in line 76:
// a short hostname (well under the 253 limit) that violates the pattern must
// still be rejected. If `||` were mutated to `&&`, this short-but-invalid host
// would slip through because the length clause is false.
func TestNormalizeHost_PatternClauseIndependentOfLength(t *testing.T) {
cases := []string{
"under_score.example.com",
"bad host",
"exa$mple.com",
"-leadingdash.com",
}
for _, in := range cases {
t.Run(in, func(t *testing.T) {
if len(in) > 253 {
t.Fatalf("test setup: %q should be short to isolate the pattern clause", in)
}
if _, err := NormalizeHost(in); err == nil {
t.Fatalf("NormalizeHost(%q) expected error for invalid pattern, got nil", in)
}
})
}
}
// TestNormalizeHost_ValidShortHostAccepted ensures a short valid-pattern host is
// accepted, so a mutation dropping the `!` on the pattern match (rejecting valid
// hosts) is caught alongside the rejection cases above.
func TestNormalizeHost_ValidShortHostAccepted(t *testing.T) {
const in = "node-1.example.com"
got, err := NormalizeHost(in)
if err != nil {
t.Fatalf("NormalizeHost(%q) returned error: %v", in, err)
}
if got != in {
t.Fatalf("NormalizeHost(%q) = %q, want %q", in, got, in)
}
}
+30
View File
@@ -3,6 +3,7 @@ package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
@@ -80,7 +81,9 @@ func TestSecurityHeadersMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SecurityHeadersMiddleware(true))
var capturedNonce string
router.GET("/", func(c *gin.Context) {
capturedNonce = c.GetString("csp_nonce")
c.String(http.StatusOK, "ok")
})
@@ -101,6 +104,33 @@ func TestSecurityHeadersMiddleware(t *testing.T) {
if got := headers.Get("Strict-Transport-Security"); got == "" {
t.Fatal("Strict-Transport-Security should be set for direct HTTPS")
}
// CSP is the highest-value header here: assert it stays nonce-bound with its hardening
// directives, so weakening it (unsafe-inline, dropped frame-ancestors, broken nonce) fails.
csp := headers.Get("Content-Security-Policy")
if csp == "" {
t.Fatal("Content-Security-Policy header must be set")
}
if capturedNonce == "" {
t.Fatal("csp_nonce context value must be set (the injected inline script reads it)")
}
if want := "script-src 'self' 'nonce-" + capturedNonce + "'"; !strings.Contains(csp, want) {
t.Fatalf("CSP script-src must be bound to the per-request nonce %q; got %q", want, csp)
}
for _, directive := range []string{"object-src 'none'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'"} {
if !strings.Contains(csp, directive) {
t.Errorf("CSP missing hardening directive %q; got %q", directive, csp)
}
}
// script-src must NOT allow 'unsafe-inline' (it would defeat the nonce). Check the
// script-src directive in isolation, since style-src legitimately uses unsafe-inline.
scriptDir := csp[strings.Index(csp, "script-src"):]
if i := strings.Index(scriptDir, ";"); i >= 0 {
scriptDir = scriptDir[:i]
}
if strings.Contains(scriptDir, "unsafe-inline") {
t.Errorf("CSP script-src must not allow 'unsafe-inline': %q", scriptDir)
}
}
func TestSecurityHeadersMiddlewareSkipsHSTSWithoutDirectHTTPS(t *testing.T) {
@@ -0,0 +1,99 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// The accept side of validate.go:45 — `c.ShouldBindWith(&dst, binding.JSON)` must SUCCEED
// for a well-formed JSON body and decode it into the destination struct. If the conditional
// is flipped (err != nil -> err == nil) or the bind call is dropped, a valid body would be
// rejected or the fields would come back zero-valued; both fail these assertions.
func TestBindJSONAndValidate_ValidJSONDecodesAndPasses(t *testing.T) {
var got *sampleBody
r := newRouter(func(c *gin.Context) {
var ok bool
got, ok = BindJSONAndValidate[sampleBody](c)
if !ok {
t.Fatalf("expected ok=true for valid JSON; got false")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":443,"protocol":"vless","tag":"inbound-443"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
if got == nil {
t.Fatal("expected decoded struct; got nil")
}
if got.Port != 443 || got.Protocol != "vless" || got.Tag != "inbound-443" {
t.Fatalf("decoded JSON mismatch: %+v", got)
}
}
// The reject side of validate.go:45 — a malformed JSON body must be caught by the bind
// conditional, returning (nil,false) with a parse-error Message and NO validator Issues.
// If the conditional is flipped so malformed input bypasses the bind check, control falls
// through to validate.Struct on a zero-valued struct, which would instead emit validator
// Issues (e.g. rule="required"/"gte"). Asserting empty Issues + non-empty Message pins the
// distinct parse-failure path that line 45 owns.
func TestBindJSONAndValidate_MalformedJSONRejectedWithoutValidatorIssues(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindJSONAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false on malformed JSON; got true")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
msg := decodeMsg(t, rec.Body.String())
if msg.Success {
t.Fatal("expected Success=false on malformed JSON")
}
payload, err := payloadFromObj(msg.Obj)
if err != nil {
t.Fatalf("payload extraction: %v", err)
}
if len(payload.Issues) != 0 {
t.Fatalf("expected empty Issues for a JSON parse error (not validator output); got %+v", payload.Issues)
}
if payload.Message == "" {
t.Fatal("expected non-empty Message describing the JSON parse error")
}
}
// BindJSONAndValidateInto shares the same line-45-style bind conditional (line 57). Cover its
// accept side: a valid JSON body must bind onto the caller-supplied destination and pass,
// overwriting any pre-populated field. A flipped/dropped bind check leaves the destination
// untouched (or returns false), which these assertions catch.
func TestBindJSONAndValidateInto_ValidJSONBindsOntoDestination(t *testing.T) {
dst := &sampleBody{Tag: "preset"}
r := newRouter(func(c *gin.Context) {
if !BindJSONAndValidateInto(c, dst) {
t.Fatal("expected ok=true for valid JSON; got false")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":8443,"protocol":"trojan","tag":"inbound-8443"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
if dst.Port != 8443 || dst.Protocol != "trojan" {
t.Fatalf("expected JSON to bind onto destination; got %+v", dst)
}
if dst.Tag != "inbound-8443" {
t.Fatalf("expected payload Tag to overwrite preset; got %q", dst.Tag)
}
}
-3
View File
@@ -87,9 +87,6 @@ func (r *Remote) baseURL() (string, error) {
return "", fmt.Errorf("invalid node port %d", r.node.Port)
}
bp := r.node.BasePath
if bp == "" {
bp = "/"
}
if !strings.HasSuffix(bp, "/") {
bp += "/"
}
+117
View File
@@ -1,12 +1,20 @@
package runtime
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
type stubEgress struct{ url string }
func (s stubEgress) NodeEgressProxyURL(int) string { return s.url }
// cacheGetTag must resolve a remote inbound id even when the n<id>- prefix
// sits on only one side: the node may store the bare tag while the central
// panel pushes the prefixed form, or vice versa. Without this a mismatch makes
@@ -50,6 +58,115 @@ func TestWireInboundIncludesShareAddressFields(t *testing.T) {
}
}
func TestRemoteHTTPClientEgressProxy(t *testing.T) {
// OutboundTag + a resolver → a dedicated proxy client (not the shared default).
withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"})
c, err := withTag.httpClient()
if err != nil {
t.Fatalf("httpClient: %v", err)
}
if c == defaultNodeHTTPClient {
t.Fatal("OutboundTag + resolver must produce a dedicated egress client, not the shared default")
}
// No OutboundTag → no egress proxy → shared default client (verify mode).
noTag := NewRemote(&model.Node{Id: 2, Scheme: "https", TlsVerifyMode: "verify"}, stubEgress{url: "socks5://127.0.0.1:1080"})
c2, err := noTag.httpClient()
if err != nil {
t.Fatalf("httpClient: %v", err)
}
if c2 != defaultNodeHTTPClient {
t.Fatal("no OutboundTag must use the shared default client")
}
}
func TestRemoteDoSetsContentType(t *testing.T) {
var gotCT string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotCT = r.Header.Get("Content-Type")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
}))
defer srv.Close()
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
if _, err := r.do(context.Background(), http.MethodPost, "x", url.Values{"a": {"b"}}); err != nil {
t.Fatalf("do: %v", err)
}
if gotCT != "application/x-www-form-urlencoded" {
t.Fatalf("Content-Type = %q, want application/x-www-form-urlencoded", gotCT)
}
}
func TestRemoteBaseURL(t *testing.T) {
cases := []struct {
name string
scheme string
port int
bp string
want string
wantErr bool
}{
{"https default path", "https", 443, "", "https://example.com:443/", false},
{"http custom path gets trailing slash", "http", 8080, "/panel", "http://example.com:8080/panel/", false},
{"empty scheme defaults to https", "", 2096, "/", "https://example.com:2096/", false},
{"invalid scheme defaults to https", "ftp", 2096, "/", "https://example.com:2096/", false},
{"port zero rejected", "https", 0, "/", "", true},
{"port above range rejected", "https", 65536, "/", "", true},
{"negative port rejected", "https", -1, "/", "", true},
{"max port accepted", "https", 65535, "/", "https://example.com:65535/", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := NewRemote(&model.Node{Address: "example.com", Scheme: c.scheme, Port: c.port, BasePath: c.bp}, nil)
got, err := r.baseURL()
if c.wantErr {
if err == nil {
t.Fatalf("expected error for scheme=%q port=%d", c.scheme, c.port)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != c.want {
t.Fatalf("baseURL = %q, want %q", got, c.want)
}
})
}
}
func TestIsNonEmptySlice(t *testing.T) {
cases := []struct {
name string
in any
want bool
}{
{"non-empty slice", []any{1}, true},
{"empty slice", []any{}, false},
{"nil slice", []any(nil), false},
{"not a slice", "x", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isNonEmptySlice(c.in); got != c.want {
t.Fatalf("isNonEmptySlice(%#v) = %v, want %v", c.in, got, c.want)
}
})
}
}
func TestWireInboundTrafficReset(t *testing.T) {
with := wireInbound(&model.Inbound{TrafficReset: "daily"})
if got := with.Get("trafficReset"); got != "daily" {
t.Fatalf("trafficReset = %q, want daily", got)
}
// Empty TrafficReset must be omitted entirely, not sent as an empty field.
without := wireInbound(&model.Inbound{})
if without.Has("trafficReset") {
t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset"))
}
}
func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) {
values := wireInbound(&model.Inbound{})
@@ -0,0 +1,73 @@
package runtime
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
"testing"
"pgregory.net/rapid"
)
func insertColons(h string) string {
var b strings.Builder
for i := 0; i < len(h); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(h[i : i+2])
}
return b.String()
}
// TestProp_DecodeCertPin_FormatAgnostic asserts that for ANY 32-byte pin, every
// accepted encoding (hex lower/upper, openssl colon-hex, base64 std/raw/url) decodes
// back to the same bytes. Generalizes the fixed-input TestDecodeCertPin so a mutant
// that breaks one decoding path is caught across the whole input space.
func TestProp_DecodeCertPin_FormatAgnostic(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
raw := rapid.SliceOfN(rapid.Byte(), sha256.Size, sha256.Size).Draw(t, "raw")
hx := hex.EncodeToString(raw)
forms := []string{
hx,
strings.ToUpper(hx),
insertColons(hx),
base64.StdEncoding.EncodeToString(raw),
base64.RawStdEncoding.EncodeToString(raw),
base64.URLEncoding.EncodeToString(raw),
base64.RawURLEncoding.EncodeToString(raw),
}
for _, f := range forms {
got, err := DecodeCertPin(f)
if err != nil {
t.Fatalf("DecodeCertPin(%q) errored: %v", f, err)
}
if !bytes.Equal(got, raw) {
t.Fatalf("DecodeCertPin(%q) = %x, want %x", f, got, raw)
}
}
})
}
// FuzzDecodeCertPin asserts the security-load-bearing decoder never panics, never
// returns a non-32-byte slice with a nil error, and never returns bytes alongside an
// error. Seeded from the known-good/known-bad cases.
func FuzzDecodeCertPin(f *testing.F) {
seed := sha256.Sum256([]byte("seed"))
f.Add(hex.EncodeToString(seed[:]))
f.Add(base64.StdEncoding.EncodeToString(seed[:]))
f.Add(insertColons(hex.EncodeToString(seed[:])))
f.Add("")
f.Add("not-a-pin")
f.Fuzz(func(t *testing.T, s string) {
got, err := DecodeCertPin(s)
if err == nil && len(got) != sha256.Size {
t.Fatalf("DecodeCertPin(%q): nil error but %d bytes, want %d", s, len(got), sha256.Size)
}
if err != nil && got != nil {
t.Fatalf("DecodeCertPin(%q): error %v but returned bytes %x", s, err, got)
}
})
}
+61 -2
View File
@@ -116,8 +116,67 @@ func TestHTTPClientForNodeVerifyShared(t *testing.T) {
}
func TestHTTPClientForNodePinInvalid(t *testing.T) {
if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}, ""); err == nil {
t.Fatal("expected error for invalid pin")
// pin mode must fail closed, and with a specific error per cause — not merely
// "some error" (which a bug anywhere in the build path would also satisfy).
cases := []struct {
name string
pin string
wantErr string
}{
{"garbage pin", "not-a-pin", "must be a SHA-256 hash"},
{"empty pin", "", "certificate pin is empty"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "")
if err == nil {
t.Fatalf("expected error for pin %q", c.pin)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr)
}
})
}
}
// TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch
// (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's
// transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg`
// line). Dropping it would silently disable certificate pinning whenever a proxy is set.
func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) {
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin}
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
if c == defaultNodeHTTPClient {
t.Fatal("proxy client must not be the shared default client")
}
tr, ok := c.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport is %T, want *http.Transport", c.Transport)
}
if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil {
t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped")
}
}
// TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch
// (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is,
// using system-CA verification and NOT a pin VerifyConnection.
func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) {
n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"}
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
if c == defaultNodeHTTPClient {
t.Fatal("proxy client must not be the shared default client")
}
if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil {
t.Fatal("verify mode must not install a pin VerifyConnection")
}
}
+1 -1
View File
@@ -244,7 +244,7 @@ func (s *InboundService) MigrationRequirements() {
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE position('0.0.0.0:' in tag) > 0;`
}
err = tx.Raw(tagCleanup).Error
err = tx.Exec(tagCleanup).Error
if err != nil {
return
}
@@ -90,6 +90,45 @@ func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *
}
}
// TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that
// strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the
// externalProxy detection query returns rows and the cleanup is reached (it early-returns
// at len(externalProxy)==0 otherwise). The cleanup must use tx.Exec, not tx.Raw, which
// only builds a non-SELECT statement without running it.
func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
legacy := &model.Inbound{
UserId: 1,
Tag: "inbound-0.0.0.0:30002",
Enable: true,
Port: 30002,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
}
if err := db.Create(legacy).Error; err != nil {
t.Fatalf("create legacy inbound: %v", err)
}
svc := InboundService{}
svc.MigrationRequirements()
var got model.Inbound
if err := db.First(&got, legacy.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
if got.Tag != "inbound-30002" {
t.Fatalf("legacy 0.0.0.0: tag not stripped: got %q, want %q", got.Tag, "inbound-30002")
}
}
func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
+345
View File
@@ -0,0 +1,345 @@
package xray
import (
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
)
// ---------------------------------------------------------------------------
// hot_diff.go mutation audits
// ---------------------------------------------------------------------------
// TestDiffOutbounds_EmptyOutboundsNoPanic pins hot_diff.go:154 — the
// `len(oldOut) > 0` guard that protects the oldOut[0]/newOut[0] index. With no
// outbounds on either side the first-outbound identity check must be SKIPPED
// (an empty hot diff), never executed; a mutated guard (`>= 0`) would index a
// nil slice and panic.
func TestDiffOutbounds_EmptyOutboundsNoPanic(t *testing.T) {
oldCfg := makeHotConfig()
oldCfg.OutboundConfigs = nil
newCfg := makeHotConfig()
newCfg.OutboundConfigs = nil
diff, ok := ComputeHotDiff(oldCfg, newCfg)
if !ok {
t.Fatal("identical empty-outbound configs must be hot-appliable")
}
if len(diff.RemovedOutboundTags) != 0 || len(diff.AddedOutbounds) != 0 {
t.Fatalf("no outbounds on either side must yield no outbound ops, got %+v", diff)
}
}
// TestDiffOutbounds_SingleFirstOutboundChangeNeedsRestart pins the other side
// of the hot_diff.go:154 boundary. With exactly ONE outbound, changing its
// content touches the default (first) handler, which has no replace API — it
// must force a restart. A mutated guard (`> 1`) would skip the first-outbound
// check at this length and wrongly classify the change as hot-appliable.
func TestDiffOutbounds_SingleFirstOutboundChangeNeedsRestart(t *testing.T) {
oldCfg := makeHotConfig()
oldCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"}]`)
newCfg := makeHotConfig()
newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"UseIP"},"tag":"direct"}]`)
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("changing the only (default) outbound must force a restart")
}
}
// TestRoutingWithoutReloadable_EmptyInput pins hot_diff.go:219 — the
// `len(raw) > 0` guard that skips JSON decoding of empty input. Empty input
// must canonicalize to the empty object `{}` with ok=true (no rules/balancers
// to strip). A mutated guard (`>= 0`) would feed an empty reader to the JSON
// decoder, get io.EOF, and wrongly return ok=false.
func TestRoutingWithoutReloadable_EmptyInput(t *testing.T) {
out, ok := routingWithoutReloadable([]byte{})
if !ok {
t.Fatal("empty routing input must canonicalize successfully")
}
if string(out) != "{}" {
t.Fatalf("empty routing input must canonicalize to {}, got %q", out)
}
// nil input behaves the same as empty.
out, ok = routingWithoutReloadable(nil)
if !ok || string(out) != "{}" {
t.Fatalf("nil routing input must canonicalize to {}, ok=%v out=%q", ok, out)
}
}
// TestRoutingWithoutReloadable_StripsRulesAndBalancers complements the guard
// test: with real content the reloadable keys (rules, balancers) are removed
// and only the restart-only remainder is returned. This pins that a routing
// change limited to rules/balancers leaves an identical remainder.
func TestRoutingWithoutReloadable_StripsRulesAndBalancers(t *testing.T) {
a, ok := routingWithoutReloadable([]byte(`{"domainStrategy":"AsIs","rules":[{"x":1}],"balancers":[{"y":2}]}`))
if !ok {
t.Fatal("valid routing input must parse")
}
b, ok := routingWithoutReloadable([]byte(`{"domainStrategy":"AsIs","rules":[],"balancers":[]}`))
if !ok {
t.Fatal("valid routing input must parse")
}
if string(a) != string(b) {
t.Fatalf("rules/balancers must be stripped: %q != %q", a, b)
}
if string(a) != `{"domainStrategy":"AsIs"}` {
t.Fatalf("remainder must keep only restart-only keys, got %q", a)
}
}
// TestApiTagFromConfig pins hot_diff.go:357 — the three-part guard
// `len(api) > 0 && Unmarshal == nil && parsed.Tag != ""`. Each conjunct must
// hold for a custom tag to be honored; otherwise the default "api" is used.
func TestApiTagFromConfig(t *testing.T) {
cases := []struct {
name string
api string
want string
}{
{"empty input falls back to api", "", "api"},
{"explicit tag honored", `{"tag":"my-api"}`, "my-api"},
{"empty tag falls back to api", `{"tag":""}`, "api"},
{"missing tag falls back to api", `{"services":["StatsService"]}`, "api"},
{"unparsable falls back to api", `{not-json`, "api"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := apiTagFromConfig(json_util.RawMessage(tc.api))
if got != tc.want {
t.Fatalf("apiTagFromConfig(%q) = %q, want %q", tc.api, got, tc.want)
}
})
}
}
// TestApiTagDrivesInboundRestartGuard ties hot_diff.go:357 to its consumer:
// the api tag resolved from the api section is the tag whose inbound change
// forces a restart. With a custom api.tag, changing that inbound must NOT be
// hot-appliable (it carries the gRPC server the panel talks through).
func TestApiTagDrivesInboundRestartGuard(t *testing.T) {
oldCfg := makeHotConfig()
oldCfg.API = json_util.RawMessage(`{"services":["HandlerService"],"tag":"custom-api"}`)
oldCfg.InboundConfigs[0].Tag = "custom-api"
newCfg := makeHotConfig()
newCfg.API = json_util.RawMessage(`{"services":["HandlerService"],"tag":"custom-api"}`)
newCfg.InboundConfigs[0].Tag = "custom-api"
newCfg.InboundConfigs[0].Port = 62790 // change the custom-api inbound
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("changing the inbound named by a custom api.tag must force a restart")
}
}
// ---------------------------------------------------------------------------
// process.go mutation audits (pure-logic, cross-platform)
// ---------------------------------------------------------------------------
// TestIsRunning_ExitedProcessWithClosedDone pins process.go:240 — the
// `if p.done != nil` guard that decides whether to consult the done channel.
// When the process has exited (done closed) but ProcessState has not yet been
// observed, IsRunning must report false via the closed-channel select. A
// mutated guard (`== nil`) would skip the select and wrongly report true.
func TestIsRunning_ExitedProcessWithClosedDone(t *testing.T) {
p := newProcess(nil)
p.cmd = &exec.Cmd{Process: &os.Process{}}
done := make(chan struct{})
close(done)
p.done = done
if p.IsRunning() {
t.Fatal("a process whose done channel is closed must report not running")
}
}
// TestIsRunning_LiveProcessWithOpenDone is the complementary case: an open
// done channel and no ProcessState means the process is alive, so IsRunning
// must report true (the select's default branch is taken).
func TestIsRunning_LiveProcessWithOpenDone(t *testing.T) {
p := newProcess(nil)
p.cmd = &exec.Cmd{Process: &os.Process{}}
p.done = make(chan struct{}) // open
if !p.IsRunning() {
t.Fatal("a process with an open done channel and live cmd must report running")
}
}
// TestGetResult pins process.go:260 — the
// `if len(lastLine) == 0 && exitErr != nil` choice between the captured log
// line and the exit error string.
func TestGetResult(t *testing.T) {
cases := []struct {
name string
lastLine string
exitErr error
want string
}{
{"no line, has error -> error string", "", errProcessTest("boom"), "boom"},
{"has line -> line wins over error", "last log", errProcessTest("boom"), "last log"},
{"no line, no error -> empty", "", nil, ""},
{"has line, no error -> line", "last log", nil, "last log"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := newProcess(nil)
p.logWriter.lastLine = tc.lastLine
p.exitErr = tc.exitErr
if got := p.GetResult(); got != tc.want {
t.Fatalf("GetResult() = %q, want %q", got, tc.want)
}
})
}
}
type errProcessTest string
func (e errProcessTest) Error() string { return string(e) }
// TestRefreshLocalOnline_GraceBoundaryEmails pins the exact `<` boundary at
// process.go:407: an email idle for EXACTLY graceMs must be aged out (the
// window is half-open, age < grace). A mutated comparison (`<=`) would keep it.
func TestRefreshLocalOnline_GraceBoundaryEmails(t *testing.T) {
p := newOnlineTestProcess()
const grace = int64(20000)
p.RefreshLocalOnline([]string{"edge"}, nil, 0, grace)
// now-ts == grace exactly: age is not strictly < grace, so it must drop.
p.RefreshLocalOnline(nil, nil, grace, grace)
for _, e := range p.GetLocalOnlineClients() {
if e == "edge" {
t.Fatalf("email idle exactly graceMs must age out (half-open window), got online %v", p.GetLocalOnlineClients())
}
}
// One millisecond inside the window must still be online.
p2 := newOnlineTestProcess()
p2.RefreshLocalOnline([]string{"edge"}, nil, 0, grace)
p2.RefreshLocalOnline(nil, nil, grace-1, grace)
if !containsString(p2.GetLocalOnlineClients(), "edge") {
t.Fatalf("email idle graceMs-1 must still be online, got %v", p2.GetLocalOnlineClients())
}
}
// TestRefreshLocalOnline_GraceBoundaryInbounds pins the same `<` boundary at
// process.go:423 for inbound tags.
func TestRefreshLocalOnline_GraceBoundaryInbounds(t *testing.T) {
p := newOnlineTestProcess()
const grace = int64(20000)
p.RefreshLocalOnline(nil, []string{"in-edge"}, 0, grace)
p.RefreshLocalOnline(nil, nil, grace, grace)
for _, tag := range p.GetLocalActiveInbounds() {
if tag == "in-edge" {
t.Fatalf("inbound idle exactly graceMs must age out, got active %v", p.GetLocalActiveInbounds())
}
}
p2 := newOnlineTestProcess()
p2.RefreshLocalOnline(nil, []string{"in-edge"}, 0, grace)
p2.RefreshLocalOnline(nil, nil, grace-1, grace)
if !containsString(p2.GetLocalActiveInbounds(), "in-edge") {
t.Fatalf("inbound idle graceMs-1 must still be active, got %v", p2.GetLocalActiveInbounds())
}
}
func containsString(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// process.go mutation audits (require a real child process; re-invoke the
// test binary so they run cross-platform, no signals needed)
// ---------------------------------------------------------------------------
// TestWaitForCommand_CrashExitRecordsError pins process.go:554 — the
// `if err == nil || intentionalStop` guard. A process that exits with a
// NON-zero code on its own (not an intentional Stop) is a crash and its error
// MUST be recorded. A mutated guard that negates the err check (`err != nil`)
// would early-return and drop the error.
func TestWaitForCommand_CrashExitRecordsError(t *testing.T) {
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "crash-exit")
cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
p := newProcess(nil)
if err := p.startCommand(cmd); err != nil {
t.Fatalf("startCommand: %v", err)
}
// We never call Stop -> intentionalStop stays false; the child exits 2.
if err := p.waitForExit(5 * time.Second); err != nil {
t.Fatalf("child did not exit: %v", err)
}
if p.GetErr() == nil {
t.Fatal("a non-intentional non-zero exit must record an error")
}
}
// TestStop_RemovesTempConfigFile pins process.go:579 — the
// `if p.configPath != ""` guard that removes the per-run temp config file on
// Stop (so test runs never disturb the main config.json). A mutated guard
// (`== ""`) would skip the removal and leak the temp file.
func TestStop_RemovesTempConfigFile(t *testing.T) {
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
tmpCfg := filepath.Join(t.TempDir(), "test-config.json")
if err := os.WriteFile(tmpCfg, []byte("{}"), 0o644); err != nil {
t.Fatalf("write temp config: %v", err)
}
cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "block")
cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
p := newProcess(nil)
p.configPath = tmpCfg
if err := p.startCommand(cmd); err != nil {
t.Fatalf("startCommand: %v", err)
}
t.Cleanup(func() {
if p.IsRunning() {
p.intentionalStop.Store(true)
_ = p.cmd.Process.Kill()
_ = p.waitForExit(2 * time.Second)
}
})
if !p.IsRunning() {
t.Fatal("helper process must be running before Stop")
}
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if _, err := os.Stat(tmpCfg); !os.IsNotExist(err) {
t.Fatalf("temp config file must be removed on Stop, stat err=%v", err)
}
}
// TestMutationAuditHelper is the re-invoked child for the process tests above.
// It is inert unless XRAY_MUT_HELPER=1 is set.
func TestMutationAuditHelper(t *testing.T) {
if os.Getenv("XRAY_MUT_HELPER") != "1" {
return
}
mode := ""
for i, arg := range os.Args {
if arg == "--" && i+1 < len(os.Args) {
mode = os.Args[i+1]
break
}
}
switch mode {
case "crash-exit":
os.Exit(2)
case "block":
select {}
}
}