- Clash→sing-box template conversion engine (singbox_template.go) - Auto-map 40+ Clash rule-providers to sing-box .srs binary rule-sets - Fix sing-box 1.11+/1.12+ migrations (sniff/block/WireGuard/DNS/rule_set) - Fix WireGuard URI '+' parsing bug (rawParamGet) - Add REJECT block outbound for selector group references - Skip rules referencing rule-sets with no sing-box equivalent - Verified: sing-box run succeeds with ACL4SSR template (16 rule-sets loaded) - Add Dockerfile (multi-stage Go build) - Add GitHub Actions workflow (multi-arch: amd64+arm64, push to ghcr.io) - Add frontend scaffold (Vue3 + Vite + Tailwind)
2922 lines
80 KiB
Go
2922 lines
80 KiB
Go
package render
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/peterqiu0516/sub-store/internal/model"
|
|
)
|
|
|
|
// --- test fixtures ---
|
|
|
|
// allNodeTypes returns one node per supported proxy type, with realistic
|
|
// fields that exercise every renderer branch.
|
|
func allNodeTypes() []model.ProxyNode {
|
|
return []model.ProxyNode{
|
|
{
|
|
"type": "ss",
|
|
"name": "SS-Node",
|
|
"server": "ss.example.com",
|
|
"port": 8388,
|
|
"cipher": "aes-256-gcm",
|
|
"password": "ss-pass",
|
|
"udp": true,
|
|
"tfo": true,
|
|
"skip-cert-verify": true,
|
|
"alpn": []any{"h2", "http/1.1"},
|
|
"plugin": "obfs",
|
|
"plugin-opts": map[string]any{"mode": "http", "host": "obfs.example.com", "path": "/obfs"},
|
|
},
|
|
{
|
|
"type": "vmess",
|
|
"name": "VMess-Node",
|
|
"server": "vmess.example.com",
|
|
"port": 443,
|
|
"uuid": "vmess-uuid",
|
|
"alterId": 64,
|
|
"cipher": "auto",
|
|
"network": "ws",
|
|
"tls": true,
|
|
"servername": "vmess.sni.com",
|
|
"udp": true,
|
|
"ws-opts": map[string]any{
|
|
"path": "/vmess",
|
|
"headers": map[string]any{"Host": "vmess.example.com"},
|
|
},
|
|
},
|
|
{
|
|
"type": "vless",
|
|
"name": "VLESS-Node",
|
|
"server": "vless.example.com",
|
|
"port": 443,
|
|
"uuid": "vless-uuid",
|
|
"network": "ws",
|
|
"tls": true,
|
|
"servername": "vless.sni.com",
|
|
"flow": "xtls-rprx-vision",
|
|
"client-fingerprint": "chrome",
|
|
"reality-opts": map[string]any{
|
|
"public-key": "realitypubkey",
|
|
"short-id": "abc123",
|
|
"spider-x": "/spider",
|
|
},
|
|
},
|
|
{
|
|
"type": "trojan",
|
|
"name": "Trojan-Node",
|
|
"server": "trojan.example.com",
|
|
"port": 443,
|
|
"password": "trojan-pass",
|
|
"sni": "trojan.sni.com",
|
|
"udp": true,
|
|
"skip-cert-verify": true,
|
|
},
|
|
{
|
|
"type": "hysteria2",
|
|
"name": "Hy2-Node",
|
|
"server": "hy2.example.com",
|
|
"port": 443,
|
|
"password": "hy2-pass",
|
|
"sni": "hy2.sni.com",
|
|
"obfs": "salamander",
|
|
"obfs-password": "obfs-pwd",
|
|
"skip-cert-verify": true,
|
|
},
|
|
{
|
|
"type": "tuic",
|
|
"name": "TUIC-Node",
|
|
"server": "tuic.example.com",
|
|
"port": 443,
|
|
"uuid": "tuic-uuid",
|
|
"password": "tuic-pass",
|
|
"sni": "tuic.sni.com",
|
|
"udp-relay-mode": "quic",
|
|
"congestion-controller": "bbr",
|
|
"reduce-rtt": true,
|
|
"disable-sni": true,
|
|
"skip-cert-verify": true,
|
|
},
|
|
{
|
|
"type": "http",
|
|
"name": "HTTP-Node",
|
|
"server": "http.example.com",
|
|
"port": 8080,
|
|
"username": "http-user",
|
|
"password": "http-pass",
|
|
"tls": true,
|
|
"sni": "http.sni.com",
|
|
},
|
|
{
|
|
"type": "socks5",
|
|
"name": "SOCKS5-Node",
|
|
"server": "socks.example.com",
|
|
"port": 1080,
|
|
"username": "socks-user",
|
|
"password": "socks-pass",
|
|
"tls": true,
|
|
},
|
|
{
|
|
"type": "anytls",
|
|
"name": "AnyTLS-Node",
|
|
"server": "anytls.example.com",
|
|
"port": 443,
|
|
"password": "anytls-pass",
|
|
"sni": "anytls.sni.com",
|
|
"skip-cert-verify": true,
|
|
"client-fingerprint": "chrome",
|
|
},
|
|
{
|
|
"type": "wireguard",
|
|
"name": "WG-Node",
|
|
"server": "wg.example.com",
|
|
"port": 51820,
|
|
"private-key": "wg-privkey",
|
|
"public-key": "wg-pubkey",
|
|
"pre-shared-key": "wg-psk",
|
|
"ip": "10.0.0.2/32",
|
|
"ipv6": "fd00::2/128",
|
|
"reserved": "1,2,3",
|
|
},
|
|
{
|
|
"type": "snell",
|
|
"name": "Snell-Node",
|
|
"server": "snell.example.com",
|
|
"port": 443,
|
|
"psk": "snell-psk",
|
|
"version": 4,
|
|
},
|
|
{
|
|
"type": "ssh",
|
|
"name": "SSH-Node",
|
|
"server": "ssh.example.com",
|
|
"port": 22,
|
|
"username": "ssh-user",
|
|
"password": "ssh-pass",
|
|
"private-key": "ssh-privkey",
|
|
},
|
|
{
|
|
"type": "h2-connect",
|
|
"name": "H2-Node",
|
|
"server": "h2.example.com",
|
|
"port": 443,
|
|
"username": "h2-user",
|
|
"password": "h2-pass",
|
|
"tls": true,
|
|
"sni": "h2.sni.com",
|
|
},
|
|
{
|
|
"type": "ssr",
|
|
"name": "SSR-Node",
|
|
"server": "ssr.example.com",
|
|
"port": 8388,
|
|
"cipher": "aes-256-cfb",
|
|
"password": "ssr-pass",
|
|
"protocol": "auth_aes128_md5",
|
|
"obfs": "tls1.2_ticket_auth",
|
|
"protocol-param": "param1",
|
|
"obfs-param": "param2",
|
|
},
|
|
{
|
|
"type": "hysteria",
|
|
"name": "Hy-Node",
|
|
"server": "hy.example.com",
|
|
"port": 443,
|
|
"auth_str": "hy-auth",
|
|
"up": 100,
|
|
"down": 200,
|
|
"sni": "hy.sni.com",
|
|
"protocol": "udp",
|
|
"obfs": "salamander",
|
|
"obfs-password": "hy-obfs",
|
|
"skip-cert-verify": true,
|
|
},
|
|
}
|
|
}
|
|
|
|
// nodeByName extracts a single node from allNodeTypes by name prefix.
|
|
// Matches the first node whose name starts with the given prefix.
|
|
func nodeByName(name string) model.ProxyNode {
|
|
for _, n := range allNodeTypes() {
|
|
if strings.HasPrefix(getString(n, "name"), name) {
|
|
return n
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nodeByExactName extracts a single node from allNodeTypes by exact name match.
|
|
func nodeByExactName(name string) model.ProxyNode {
|
|
for _, n := range allNodeTypes() {
|
|
if getString(n, "name") == name {
|
|
return n
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- dispatch: RenderTarget coverage ---
|
|
|
|
func TestExtraRenderTargetAll(t *testing.T) {
|
|
nodes := allNodeTypes()
|
|
targets := []string{
|
|
model.TargetMihomo, model.TargetStash,
|
|
model.TargetSurge, model.TargetSurgeMac,
|
|
model.TargetSurfboard, model.TargetLoon,
|
|
model.TargetEgern, model.TargetQX,
|
|
model.TargetSingBox, model.TargetV2ray,
|
|
model.TargetURI, model.TargetShadowrocket,
|
|
model.TargetJSON,
|
|
}
|
|
for _, tgt := range targets {
|
|
t.Run(tgt, func(t *testing.T) {
|
|
out, err := RenderTarget(nodes, tgt, nil)
|
|
// Every target must either succeed or error with no-nodes;
|
|
// since we have 15 nodes, success is expected for all.
|
|
if err != nil {
|
|
// Surge/surfboard may reject unsupported types, but at least
|
|
// one node should be supported — so err here is unexpected.
|
|
t.Fatalf("RenderTarget(%q) returned error: %v", tgt, err)
|
|
}
|
|
if strings.TrimSpace(out) == "" {
|
|
t.Fatalf("RenderTarget(%q) returned empty output", tgt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTargetEmptyNodes(t *testing.T) {
|
|
empty := []model.ProxyNode{}
|
|
for _, tgt := range []string{
|
|
model.TargetMihomo, model.TargetSurge, model.TargetSingBox,
|
|
model.TargetV2ray, model.TargetURI, model.TargetJSON,
|
|
model.TargetLoon, model.TargetEgern, model.TargetQX,
|
|
model.TargetSurfboard, model.TargetSurgeMac,
|
|
} {
|
|
t.Run(tgt, func(t *testing.T) {
|
|
_, err := RenderTarget(empty, tgt, nil)
|
|
if err == nil {
|
|
t.Fatalf("expected error for empty nodes with target %q", tgt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTargetUnsupportedFallsBackToMihomo(t *testing.T) {
|
|
nodes := allNodeTypes()[:2]
|
|
out, err := RenderTarget(nodes, "no-such-target", nil)
|
|
if err != nil {
|
|
t.Fatalf("unsupported target should fall back to mihomo, got err: %v", err)
|
|
}
|
|
if !strings.Contains(out, "Generated by Sub-Store") {
|
|
t.Errorf("expected mihomo fallback output, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderBuildTargetAll(t *testing.T) {
|
|
nodes := allNodeTypes()
|
|
for _, tgt := range []string{
|
|
model.TargetMihomo, model.TargetStash,
|
|
model.TargetSurge, model.TargetSurgeMac,
|
|
model.TargetSurfboard, model.TargetLoon,
|
|
model.TargetEgern, model.TargetShadowrocket,
|
|
model.TargetQX, model.TargetSingBox,
|
|
model.TargetV2ray, model.TargetURI, model.TargetJSON,
|
|
} {
|
|
t.Run(tgt, func(t *testing.T) {
|
|
out, err := RenderBuildTarget(nodes, tgt, "https://sub.example.com/api/sub?target=x", nil)
|
|
if err != nil {
|
|
t.Fatalf("RenderBuildTarget(%q) error: %v", tgt, err)
|
|
}
|
|
if strings.TrimSpace(out) == "" {
|
|
t.Fatalf("RenderBuildTarget(%q) empty output", tgt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderBuildTargetFallback(t *testing.T) {
|
|
nodes := allNodeTypes()[:1]
|
|
out, err := RenderBuildTarget(nodes, "unknown-target", "https://sub.example.com/path", nil)
|
|
if err != nil {
|
|
t.Fatalf("fallback should succeed: %v", err)
|
|
}
|
|
if !strings.Contains(out, "/path") {
|
|
t.Errorf("expected requestUrl path /path in output, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestExtraSourcePath(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"https://sub.example.com/api/sub", "/api/sub"},
|
|
{"https://sub.example.com", "sub.example.com"},
|
|
{"not-a-url", "not-a-url"},
|
|
{"", ""},
|
|
{"https://sub.example.com/path?a=b", "/path"},
|
|
}
|
|
for _, c := range cases {
|
|
got := sourcePath(c.in)
|
|
if got != c.want {
|
|
t.Errorf("sourcePath(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Surge renderer ---
|
|
|
|
func TestExtraSurgeProxyLineAllTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
node model.ProxyNode
|
|
wantSub []string
|
|
}{
|
|
{"ss", nodeByName("SS"), []string{"SS-Node", "=ss,"}},
|
|
{"vmess", nodeByName("VMess"), []string{"=vmess,", "vmess-uuid", "encrypt-method=auto"}},
|
|
{"trojan", nodeByName("Trojan"), []string{"=trojan,", "trojan-pass"}},
|
|
{"http", nodeByName("HTTP"), []string{"=https,", "http.example.com", "8080"}},
|
|
{"socks5-tls", nodeByName("SOCKS5"), []string{"=socks5-tls,"}},
|
|
{"hysteria2", nodeByName("Hy2"), []string{"=hysteria2,", "hy2-pass", "obfs=salamander"}},
|
|
{"tuic", nodeByName("TUIC"), []string{"=tuic-v5,", "tuic-uuid"}},
|
|
{"anytls", nodeByName("AnyTLS"), []string{"=anytls,"}},
|
|
{"snell", nodeByName("Snell"), []string{"=snell,", "snell-psk", "version=4"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
line := ToSurgeProxyLine(c.node)
|
|
if line == "" {
|
|
t.Fatalf("ToSurgeProxyLine(%s) returned empty", c.name)
|
|
}
|
|
for _, sub := range c.wantSub {
|
|
if !strings.Contains(line, sub) {
|
|
t.Errorf("expected %q in line: %s", sub, line)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraSurgeUnsupportedType(t *testing.T) {
|
|
node := model.ProxyNode{"type": "wireguard", "name": "wg", "server": "x", "port": 1}
|
|
if line := ToSurgeProxyLine(node); line != "" {
|
|
t.Errorf("expected empty for unsupported type, got: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraSurgeSocks5WithoutTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "socks5", "name": "socks", "server": "s", "port": 1080,
|
|
"username": "u", "password": "p", "tls": false,
|
|
}
|
|
line := ToSurgeProxyLine(node)
|
|
if !strings.Contains(line, "=socks5,") {
|
|
t.Errorf("expected socks5 (not socks5-tls): %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraSurgeHTTPWithoutTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "http", "name": "h", "server": "s", "port": 80,
|
|
"tls": false,
|
|
}
|
|
line := ToSurgeProxyLine(node)
|
|
if !strings.Contains(line, "=http,") {
|
|
t.Errorf("expected http (not https): %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraSurgeHysteria2NoObfs(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "hysteria2", "name": "hy2", "server": "s", "port": 443,
|
|
"password": "p", "sni": "sni.com",
|
|
}
|
|
line := ToSurgeProxyLine(node)
|
|
if strings.Contains(line, "obfs=") {
|
|
t.Errorf("expected no obfs in line: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraSurgeSnellDefaultVersion(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "snell", "name": "snell", "server": "s", "port": 443,
|
|
"password": "p",
|
|
}
|
|
line := ToSurgeProxyLine(node)
|
|
if !strings.Contains(line, "version=3") {
|
|
t.Errorf("expected default version=3: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraSurgeSnellPskFallback(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "snell", "name": "snell", "server": "s", "port": 443,
|
|
"password": "fallback-pwd",
|
|
}
|
|
line := ToSurgeProxyLine(node)
|
|
if !strings.Contains(line, "psk=fallback-pwd") {
|
|
t.Errorf("expected psk=fallback-pwd: %s", line)
|
|
}
|
|
}
|
|
|
|
// --- Surge-Mac renderer ---
|
|
|
|
func TestExtraSurgeMacSpecificTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
node model.ProxyNode
|
|
want []string
|
|
}{
|
|
{"ssh", nodeByName("SSH"), []string{"=ssh,", "ssh.example.com", "ssh-user", "private-key=ssh-privkey"}},
|
|
{"h2-connect", nodeByName("H2"), []string{"=h2-connect,", "h2.example.com", "tls=true"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
line := ToSurgeMacProxyLine(c.node)
|
|
if line == "" {
|
|
t.Fatalf("ToSurgeMacProxyLine(%s) returned empty", c.name)
|
|
}
|
|
for _, w := range c.want {
|
|
if !strings.Contains(line, w) {
|
|
t.Errorf("expected %q in line: %s", w, line)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraSurgeMacFallsBackToSurge(t *testing.T) {
|
|
node := nodeByName("SS")
|
|
macLine := ToSurgeMacProxyLine(node)
|
|
surgeLine := ToSurgeProxyLine(node)
|
|
if macLine != surgeLine {
|
|
t.Errorf("surge-mac should delegate ss to surge; mac=%q surge=%q", macLine, surgeLine)
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderSurgeMacProxies(t *testing.T) {
|
|
nodes := allNodeTypes()
|
|
out, err := RenderSurgeMacProxies(nodes)
|
|
if err != nil {
|
|
t.Fatalf("RenderSurgeMacProxies failed: %v", err)
|
|
}
|
|
if !strings.Contains(out, "SSH-Node=ssh,") {
|
|
t.Errorf("expected ssh line in surge-mac output: %s", out)
|
|
}
|
|
if !strings.Contains(out, "H2-Node=h2-connect,") {
|
|
t.Errorf("expected h2-connect line in surge-mac output: %s", out)
|
|
}
|
|
}
|
|
|
|
// --- Surfboard renderer ---
|
|
|
|
func TestExtraSurfboardProxies(t *testing.T) {
|
|
nodes := allNodeTypes()
|
|
out, err := RenderSurfboardProxies(nodes)
|
|
if err != nil {
|
|
t.Fatalf("RenderSurfboardProxies failed: %v", err)
|
|
}
|
|
if !strings.Contains(out, "SS-Node") {
|
|
t.Errorf("expected SS-Node in surfboard output: %s", out)
|
|
}
|
|
// Surfboard does not support hysteria2/tuic/wireguard etc.
|
|
if strings.Contains(out, "Hy2-Node") {
|
|
t.Errorf("surfboard should not include hysteria2: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestExtraSurfboardEmptyNodes(t *testing.T) {
|
|
_, err := RenderSurfboardProxies([]model.ProxyNode{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty nodes")
|
|
}
|
|
}
|
|
|
|
// --- Loon renderer ---
|
|
|
|
func TestExtraLoonProxyLineAllTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
node model.ProxyNode
|
|
wantSub []string
|
|
}{
|
|
{"ss", nodeByName("SS"), []string{"=shadowsocks,", "ss.example.com", "8388"}},
|
|
{"ssr", nodeByName("SSR"), []string{"=shadowsocksr,"}},
|
|
{"vmess", nodeByName("VMess"), []string{"=vmess,", "vmess-uuid"}},
|
|
{"vless", nodeByName("VLESS"), []string{"=vless,", "vless-uuid"}},
|
|
{"trojan", nodeByName("Trojan"), []string{"=trojan,"}},
|
|
{"anytls", nodeByName("AnyTLS"), []string{"=anytls,"}},
|
|
{"http", nodeByName("HTTP"), []string{"=http,"}},
|
|
{"socks5", nodeByName("SOCKS5"), []string{"=socks5,"}},
|
|
{"hysteria2", nodeByName("Hy2"), []string{"=Hysteria2,"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
line := ToLoonProxyLine(c.node)
|
|
if line == "" {
|
|
t.Fatalf("ToLoonProxyLine(%s) empty", c.name)
|
|
}
|
|
for _, sub := range c.wantSub {
|
|
if !strings.Contains(line, sub) {
|
|
t.Errorf("expected %q in: %s", sub, line)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraLoonUnsupportedType(t *testing.T) {
|
|
node := model.ProxyNode{"type": "tuic", "name": "x", "server": "s", "port": 1}
|
|
if line := ToLoonProxyLine(node); line != "" {
|
|
t.Errorf("tuic should be unsupported in loon, got: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraLoonVlessMethodNone(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vless", "name": "vl", "server": "s", "port": 443,
|
|
"uuid": "u",
|
|
}
|
|
line := ToLoonProxyLine(node)
|
|
if !strings.Contains(line, ",none,") {
|
|
t.Errorf("expected method=none for vless: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraLoonSsDefaultCipher(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "ss", "name": "x", "server": "s", "port": 1,
|
|
"password": "p",
|
|
}
|
|
line := ToLoonProxyLine(node)
|
|
if !strings.Contains(line, ",none,") {
|
|
t.Errorf("expected default cipher=none: %s", line)
|
|
}
|
|
}
|
|
|
|
// --- QX renderer ---
|
|
|
|
func TestExtraQxProxyLineAllTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
node model.ProxyNode
|
|
wantSub []string
|
|
}{
|
|
{"ss", nodeByName("SS"), []string{"shadowsocks=ss.example.com:8388", "tag=SS-Node"}},
|
|
{"ssr", nodeByName("SSR"), []string{"shadowsocks=ssr.example.com:8388", "ssr-protocol=auth_aes128_md5"}},
|
|
{"vmess", nodeByName("VMess"), []string{"vmess=vmess.example.com:443", "method=auto"}},
|
|
{"vless", nodeByName("VLESS"), []string{"vless=vless.example.com:443", "method=none"}},
|
|
{"trojan", nodeByName("Trojan"), []string{"trojan=trojan.example.com:443", "over-tls=true"}},
|
|
{"anytls", nodeByName("AnyTLS"), []string{"anytls=anytls.example.com:443"}},
|
|
{"http", nodeByName("HTTP"), []string{"http=http.example.com:8080"}},
|
|
{"socks5", nodeByName("SOCKS5"), []string{"socks5=socks.example.com:1080"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
line := ToQxProxyLine(c.node)
|
|
if line == "" {
|
|
t.Fatalf("ToQxProxyLine(%s) empty", c.name)
|
|
}
|
|
for _, sub := range c.wantSub {
|
|
if !strings.Contains(line, sub) {
|
|
t.Errorf("expected %q in: %s", sub, line)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraQxUnsupportedType(t *testing.T) {
|
|
node := model.ProxyNode{"type": "wireguard", "name": "x", "server": "s", "port": 1}
|
|
if line := ToQxProxyLine(node); line != "" {
|
|
t.Errorf("wireguard should be unsupported in qx, got: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraQxWsTransportWss(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vmess", "name": "n", "server": "s", "port": 443,
|
|
"uuid": "u", "network": "ws", "tls": true,
|
|
"ws-opts": map[string]any{"path": "/ws", "headers": map[string]any{"Host": "h.com"}},
|
|
}
|
|
line := ToQxProxyLine(node)
|
|
if !strings.Contains(line, "obfs=wss") {
|
|
t.Errorf("expected obfs=wss for tls+ws: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraQxWsTransportWs(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vmess", "name": "n", "server": "s", "port": 80,
|
|
"uuid": "u", "network": "ws", "tls": false,
|
|
"ws-opts": map[string]any{"path": "/ws"},
|
|
}
|
|
line := ToQxProxyLine(node)
|
|
if !strings.Contains(line, "obfs=ws") {
|
|
t.Errorf("expected obfs=ws for plain ws: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraQxEmptyName(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "ss", "name": "", "server": "s", "port": 1,
|
|
"cipher": "c", "password": "p",
|
|
}
|
|
line := ToQxProxyLine(node)
|
|
if !strings.Contains(line, "tag=proxy") {
|
|
t.Errorf("expected tag=proxy for empty name: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestExtraSanitizeQxTag(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"name,with,commas", "name with commas"},
|
|
{"name\r\n", "name"},
|
|
{"=", "="}, // = is NOT replaced in qx tag
|
|
{"", "proxy"},
|
|
}
|
|
for _, c := range cases {
|
|
got := sanitizeQxTag(c.in)
|
|
if got != c.want {
|
|
t.Errorf("sanitizeQxTag(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Egern renderer ---
|
|
|
|
func TestExtraEgernAllTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
node model.ProxyNode
|
|
wantKey map[string]string
|
|
}{
|
|
{"ss", nodeByName("SS"), map[string]string{"type": "shadowsocks"}},
|
|
{"vmess", nodeByName("VMess"), map[string]string{"type": "vmess", "security": "auto"}},
|
|
{"vless", nodeByName("VLESS"), map[string]string{"type": "vless"}},
|
|
{"trojan", nodeByName("Trojan"), map[string]string{"type": "trojan"}},
|
|
{"anytls", nodeByName("AnyTLS"), map[string]string{"type": "anytls"}},
|
|
{"hysteria2", nodeByName("Hy2"), map[string]string{"type": "hysteria2"}},
|
|
{"http", nodeByName("HTTP"), map[string]string{"type": "https"}},
|
|
{"socks5", nodeByName("SOCKS5"), map[string]string{"type": "socks5_tls"}},
|
|
{"tuic", nodeByName("TUIC"), map[string]string{"type": "tuic"}},
|
|
{"wireguard", nodeByName("WG"), map[string]string{"type": "wireguard", "private_key": "wg-privkey"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
m := ToEgernProxy(c.node)
|
|
if m == nil {
|
|
t.Fatalf("ToEgernProxy(%s) nil", c.name)
|
|
}
|
|
for k, v := range c.wantKey {
|
|
got, ok := m[k]
|
|
if !ok {
|
|
t.Errorf("missing key %q in egern output: %v", k, m)
|
|
continue
|
|
}
|
|
if s, _ := got.(string); s != v {
|
|
t.Errorf("egern[%q] = %v, want %q", k, got, v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernUnsupportedType(t *testing.T) {
|
|
node := model.ProxyNode{"type": "snell", "name": "x"}
|
|
if m := ToEgernProxy(node); m != nil {
|
|
t.Errorf("snell should be unsupported in egern, got: %v", m)
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernHTTPWithoutTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "http", "name": "h", "server": "s", "port": 80, "tls": false,
|
|
}
|
|
m := ToEgernProxy(node)
|
|
if m["type"] != "http" {
|
|
t.Errorf("expected type=http, got: %v", m["type"])
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernSocks5WithoutTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "socks5", "name": "s", "server": "s", "port": 1, "tls": false,
|
|
}
|
|
m := ToEgernProxy(node)
|
|
if m["type"] != "socks5" {
|
|
t.Errorf("expected type=socks5, got: %v", m["type"])
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernWsOptions(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vmess", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "network": "ws",
|
|
"ws-opts": map[string]any{
|
|
"path": "/ws",
|
|
"headers": map[string]any{"Host": "h.com"},
|
|
},
|
|
}
|
|
m := ToEgernProxy(node)
|
|
ws, ok := m["ws_opts"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected ws_opts map, got: %v", m["ws_opts"])
|
|
}
|
|
if ws["path"] != "/ws" {
|
|
t.Errorf("expected ws path /ws, got: %v", ws["path"])
|
|
}
|
|
if _, ok := ws["headers"]; !ok {
|
|
t.Errorf("expected headers in ws_opts")
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernWsOptionsNoHeaders(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vmess", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "network": "ws",
|
|
"ws-opts": map[string]any{"path": "/ws"},
|
|
}
|
|
m := ToEgernProxy(node)
|
|
ws, _ := m["ws_opts"].(map[string]any)
|
|
if _, ok := ws["headers"]; ok {
|
|
t.Errorf("expected no headers in ws_opts when absent")
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernWsOptionsNonWs(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vmess", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "network": "tcp",
|
|
}
|
|
m := ToEgernProxy(node)
|
|
if _, ok := m["ws_opts"]; ok {
|
|
t.Errorf("expected no ws_opts for tcp network")
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernRealityOptions(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vless", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u",
|
|
"reality-opts": map[string]any{
|
|
"public-key": "pk",
|
|
"short-id": "sid",
|
|
},
|
|
}
|
|
m := ToEgernProxy(node)
|
|
r, ok := m["reality"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected reality map, got: %v", m["reality"])
|
|
}
|
|
if r["public_key"] != "pk" {
|
|
t.Errorf("expected public_key=pk, got: %v", r["public_key"])
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernRealityNoPubKey(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vless", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u",
|
|
"reality-opts": map[string]any{"short-id": "sid"},
|
|
}
|
|
m := ToEgernProxy(node)
|
|
if _, ok := m["reality"]; ok {
|
|
t.Errorf("expected no reality without public-key")
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernRenderYaml(t *testing.T) {
|
|
nodes := allNodeTypes()
|
|
out, err := RenderEgernYaml(nodes)
|
|
if err != nil {
|
|
t.Fatalf("RenderEgernYaml failed: %v", err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("egern output not valid YAML: %v\n%s", err, out)
|
|
}
|
|
proxies, ok := parsed["proxies"].([]any)
|
|
if !ok {
|
|
t.Fatal("expected proxies array in egern yaml")
|
|
}
|
|
if len(proxies) == 0 {
|
|
t.Error("expected at least one proxy in egern yaml")
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernRenderYamlEmpty(t *testing.T) {
|
|
_, err := RenderEgernYaml([]model.ProxyNode{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty egern")
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernRenderYamlAllUnsupported(t *testing.T) {
|
|
nodes := []model.ProxyNode{
|
|
{"type": "snell", "name": "x"},
|
|
}
|
|
_, err := RenderEgernYaml(nodes)
|
|
if err == nil {
|
|
t.Fatal("expected error when no supported nodes")
|
|
}
|
|
}
|
|
|
|
func TestExtraNoNodesError(t *testing.T) {
|
|
e := errNoNodes("test")
|
|
if e.Error() != "No supported nodes for test output" {
|
|
t.Errorf("unexpected error message: %s", e.Error())
|
|
}
|
|
}
|
|
|
|
func TestExtraMergeMap(t *testing.T) {
|
|
base := map[string]any{"a": 1, "b": 2}
|
|
next := map[string]any{"b": 3, "c": 4}
|
|
merged := mergeMap(base, next)
|
|
if merged["a"] != 1 || merged["b"] != 3 || merged["c"] != 4 {
|
|
t.Errorf("mergeMap unexpected: %v", merged)
|
|
}
|
|
// originals should not be mutated
|
|
if base["b"] != 2 {
|
|
t.Errorf("mergeMap mutated base")
|
|
}
|
|
}
|
|
|
|
// --- sing-box renderer ---
|
|
|
|
func TestExtraSingBoxStructure(t *testing.T) {
|
|
nodes := allNodeTypes()
|
|
out := RenderSingBoxJson(nodes)
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("sing-box output not valid JSON: %v", err)
|
|
}
|
|
// Per review-resolution #42: log, inbounds, outbounds, route
|
|
for _, key := range []string{"log", "inbounds", "outbounds", "route"} {
|
|
if _, ok := parsed[key]; !ok {
|
|
t.Errorf("missing %q in sing-box output", key)
|
|
}
|
|
}
|
|
logMap, _ := parsed["log"].(map[string]any)
|
|
if logMap == nil || logMap["level"] != "info" {
|
|
t.Errorf("expected log.level=info, got: %v", parsed["log"])
|
|
}
|
|
inbounds, _ := parsed["inbounds"].([]any)
|
|
if len(inbounds) == 0 {
|
|
t.Fatal("expected at least one inbound")
|
|
}
|
|
inbound, _ := inbounds[0].(map[string]any)
|
|
if inbound["type"] != "mixed" || inbound["tag"] != "mixed-in" {
|
|
t.Errorf("unexpected inbound: %v", inbound)
|
|
}
|
|
if inbound["listen_port"] != float64(7890) {
|
|
t.Errorf("expected listen_port=7890, got: %v", inbound["listen_port"])
|
|
}
|
|
outbounds, _ := parsed["outbounds"].([]any)
|
|
if len(outbounds) < 3 {
|
|
t.Fatalf("expected at least 3 outbounds (PROXY, AUTO, nodes, DIRECT), got %d", len(outbounds))
|
|
}
|
|
proxyOut, _ := outbounds[0].(map[string]any)
|
|
if proxyOut["tag"] != "PROXY" || proxyOut["type"] != "selector" {
|
|
t.Errorf("expected first outbound PROXY selector, got: %v", proxyOut)
|
|
}
|
|
autoOut, _ := outbounds[1].(map[string]any)
|
|
if autoOut["tag"] != "AUTO" || autoOut["type"] != "urltest" {
|
|
t.Errorf("expected second outbound AUTO urltest, got: %v", autoOut)
|
|
}
|
|
// DIRECT should be at the end (REJECT is now a route rule action, not a special outbound)
|
|
last, _ := outbounds[len(outbounds)-1].(map[string]any)
|
|
if last["tag"] != "DIRECT" || last["type"] != "direct" {
|
|
t.Errorf("expected last outbound DIRECT direct, got: %v", last)
|
|
}
|
|
// Inbound should NOT have sniff field (migrated to route rule action in sing-box 1.11+)
|
|
if _, hasSniff := inbound["sniff"]; hasSniff {
|
|
t.Errorf("inbound should not have sniff field (migrated to route rule action)")
|
|
}
|
|
// Route should have rules with sniff and reject actions
|
|
routeMap, _ := parsed["route"].(map[string]any)
|
|
rules, _ := routeMap["rules"].([]any)
|
|
if len(rules) < 2 {
|
|
t.Fatalf("expected at least 2 route rules (sniff, reject), got %d", len(rules))
|
|
}
|
|
rule0, _ := rules[0].(map[string]any)
|
|
if rule0["action"] != "sniff" {
|
|
t.Errorf("expected first route rule action=sniff, got: %v", rule0["action"])
|
|
}
|
|
rule1, _ := rules[1].(map[string]any)
|
|
if rule1["action"] != "reject" {
|
|
t.Errorf("expected second route rule action=reject, got: %v", rule1["action"])
|
|
}
|
|
if routeMap == nil || routeMap["final"] != "PROXY" {
|
|
t.Errorf("expected route.final=PROXY, got: %v", parsed["route"])
|
|
}
|
|
if routeMap["auto_detect_interface"] != true {
|
|
t.Errorf("expected auto_detect_interface=true, got: %v", routeMap["auto_detect_interface"])
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxOutboundAllTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
node model.ProxyNode
|
|
wantType string
|
|
}{
|
|
{"ss", nodeByName("SS"), "shadowsocks"},
|
|
{"vmess", nodeByName("VMess"), "vmess"},
|
|
{"vless", nodeByName("VLESS"), "vless"},
|
|
{"trojan", nodeByName("Trojan"), "trojan"},
|
|
{"hysteria2", nodeByExactName("Hy2-Node"), "hysteria2"},
|
|
{"hysteria", nodeByExactName("Hy-Node"), "hysteria"},
|
|
{"anytls", nodeByName("AnyTLS"), "anytls"},
|
|
{"tuic", nodeByName("TUIC"), "tuic"},
|
|
{"socks5", nodeByName("SOCKS5"), "socks"},
|
|
{"http", nodeByName("HTTP"), "http"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
out := ToSingBoxOutbound(c.node)
|
|
if out == nil {
|
|
t.Fatalf("ToSingBoxOutbound(%s) nil", c.name)
|
|
}
|
|
if out["type"] != c.wantType {
|
|
t.Errorf("sing-box[%s].type = %v, want %q", c.name, out["type"], c.wantType)
|
|
}
|
|
if out["tag"] != getString(c.node, "name") {
|
|
t.Errorf("sing-box[%s].tag = %v, want %q", c.name, out["tag"], getString(c.node, "name"))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxWireGuardEndpoint(t *testing.T) {
|
|
node := nodeByName("WG")
|
|
ep := ToSingBoxWireGuardEndpoint(node)
|
|
if ep == nil {
|
|
t.Fatalf("ToSingBoxWireGuardEndpoint(WG) nil")
|
|
}
|
|
if ep["type"] != "wireguard" {
|
|
t.Errorf("expected type=wireguard, got %v", ep["type"])
|
|
}
|
|
if ep["tag"] != "WG-Node" {
|
|
t.Errorf("expected tag=WG-Node, got %v", ep["tag"])
|
|
}
|
|
peers, ok := ep["peers"].([]any)
|
|
if !ok || len(peers) == 0 {
|
|
t.Fatalf("expected peers array, got: %v", ep["peers"])
|
|
}
|
|
peer, ok := peers[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected peer map, got: %v", peers[0])
|
|
}
|
|
if peer["public_key"] == nil {
|
|
t.Errorf("expected peer.public_key to be set")
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxUnsupportedType(t *testing.T) {
|
|
node := model.ProxyNode{"type": "snell", "name": "x"}
|
|
if out := ToSingBoxOutbound(node); out != nil {
|
|
t.Errorf("snell should be unsupported in sing-box, got: %v", out)
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxVlessReality(t *testing.T) {
|
|
node := nodeByName("VLESS")
|
|
out := ToSingBoxOutbound(node)
|
|
tls, ok := out["tls"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected tls map, got: %v", out["tls"])
|
|
}
|
|
reality, ok := tls["reality"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected reality map under tls, got: %v", tls["reality"])
|
|
}
|
|
if reality["public_key"] != "realitypubkey" {
|
|
t.Errorf("expected reality public_key, got: %v", reality["public_key"])
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxVlessNoTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vless", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "tls": false,
|
|
}
|
|
out := ToSingBoxOutbound(node)
|
|
if _, ok := out["tls"]; ok {
|
|
t.Errorf("expected no tls field when tls=false")
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxHysteria2Obfs(t *testing.T) {
|
|
node := nodeByName("Hy2")
|
|
out := ToSingBoxOutbound(node)
|
|
obfs, ok := out["obfs"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected obfs map, got: %v", out["obfs"])
|
|
}
|
|
if obfs["type"] != "salamander" {
|
|
t.Errorf("expected obfs.type=salamander, got: %v", obfs["type"])
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxHysteria2NoObfs(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "hysteria2", "name": "h", "server": "s", "port": 443,
|
|
"password": "p",
|
|
}
|
|
out := ToSingBoxOutbound(node)
|
|
if _, ok := out["obfs"]; ok {
|
|
t.Errorf("expected no obfs field when obfs absent")
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxSocks5Tls(t *testing.T) {
|
|
node := nodeByName("SOCKS5")
|
|
out := ToSingBoxOutbound(node)
|
|
tls, ok := out["tls"].(map[string]any)
|
|
if !ok || tls["enabled"] != true {
|
|
t.Errorf("expected tls.enabled=true for socks5+tls, got: %v", out["tls"])
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxSocks5NoTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "socks5", "name": "s", "server": "s", "port": 1,
|
|
"tls": false,
|
|
}
|
|
out := ToSingBoxOutbound(node)
|
|
if _, ok := out["tls"]; ok {
|
|
t.Errorf("expected no tls field for plain socks5")
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxHTTPTls(t *testing.T) {
|
|
node := nodeByName("HTTP")
|
|
out := ToSingBoxOutbound(node)
|
|
tls, ok := out["tls"].(map[string]any)
|
|
if !ok || tls["enabled"] != true {
|
|
t.Errorf("expected tls.enabled=true for https, got: %v", out["tls"])
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxWireGuardReserved(t *testing.T) {
|
|
node := nodeByName("WG")
|
|
ep := ToSingBoxWireGuardEndpoint(node)
|
|
peers, ok := ep["peers"].([]any)
|
|
if !ok || len(peers) == 0 {
|
|
t.Fatalf("expected peers array, got: %v", ep["peers"])
|
|
}
|
|
peer := peers[0].(map[string]any)
|
|
reserved, ok := peer["reserved"].([]int)
|
|
if !ok {
|
|
t.Fatalf("expected peer reserved []int, got: %v", peer["reserved"])
|
|
}
|
|
if !reflect.DeepEqual(reserved, []int{1, 2, 3}) {
|
|
t.Errorf("expected reserved=[1,2,3], got: %v", reserved)
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxWireGuardLocalAddress(t *testing.T) {
|
|
node := nodeByName("WG")
|
|
ep := ToSingBoxWireGuardEndpoint(node)
|
|
addr, ok := ep["address"].([]string)
|
|
if !ok {
|
|
t.Fatalf("expected address, got: %v", ep["address"])
|
|
}
|
|
if len(addr) != 2 {
|
|
t.Errorf("expected 2 addresses (ip+ipv6), got %d", len(addr))
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxVmessTransport(t *testing.T) {
|
|
node := nodeByName("VMess")
|
|
out := ToSingBoxOutbound(node)
|
|
transport, ok := out["transport"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected transport map, got: %v", out["transport"])
|
|
}
|
|
if transport["type"] != "ws" {
|
|
t.Errorf("expected transport.type=ws, got: %v", transport["type"])
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxVmessNoTransport(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vmess", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "network": "tcp",
|
|
}
|
|
out := ToSingBoxOutbound(node)
|
|
if _, ok := out["transport"]; ok {
|
|
t.Errorf("expected no transport for tcp vmess")
|
|
}
|
|
}
|
|
|
|
func TestExtraSingBoxVmessNoTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vmess", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "tls": false,
|
|
}
|
|
out := ToSingBoxOutbound(node)
|
|
if _, ok := out["tls"]; ok {
|
|
t.Errorf("expected no tls field for plain vmess")
|
|
}
|
|
}
|
|
|
|
func TestExtraParseWireGuardReserved(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input any
|
|
want []int
|
|
}{
|
|
{"array", []any{1, 2, 3}, []int{1, 2, 3}},
|
|
{"array_with_zero", []any{0, 1, 2}, []int{1, 2}},
|
|
{"array_empty", []any{}, nil},
|
|
{"int_slice", []int{1, 2}, []int{1, 2}},
|
|
{"string", "1,2,3", []int{1, 2, 3}},
|
|
{"empty_string", "", nil},
|
|
{"nil", nil, nil},
|
|
{"other_type", 42, nil},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := parseWireGuardReserved(c.input)
|
|
if !reflect.DeepEqual(got, c.want) {
|
|
t.Errorf("parseWireGuardReserved(%v) = %v, want %v", c.input, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraSplitComma(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want []string
|
|
}{
|
|
{"a,b,c", []string{"a", "b", "c"}},
|
|
{"", []string{""}},
|
|
{"abc", []string{"abc"}},
|
|
{",a", []string{"", "a"}},
|
|
{"a,", []string{"a", ""}},
|
|
}
|
|
for _, c := range cases {
|
|
got := splitComma(c.in)
|
|
if !reflect.DeepEqual(got, c.want) {
|
|
t.Errorf("splitComma(%q) = %v, want %v", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- URI renderer ---
|
|
|
|
func TestExtraToProxyUriAllTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
node model.ProxyNode
|
|
scheme string
|
|
contains string
|
|
}{
|
|
{"ss", nodeByName("SS"), "ss://", ""},
|
|
{"ssr", nodeByName("SSR"), "ssr://", ""},
|
|
{"vmess", nodeByName("VMess"), "vmess://", ""},
|
|
{"vless", nodeByName("VLESS"), "vless://", "vless-uuid"},
|
|
{"trojan", nodeByName("Trojan"), "trojan://", "trojan-pass"},
|
|
{"hysteria2", nodeByExactName("Hy2-Node"), "hysteria2://", "hy2-pass"},
|
|
{"hysteria", nodeByExactName("Hy-Node"), "hysteria://", "hy-auth"},
|
|
{"anytls", nodeByName("AnyTLS"), "anytls://", "anytls-pass"},
|
|
{"tuic", nodeByName("TUIC"), "tuic://", "tuic-uuid"},
|
|
{"socks5", nodeByName("SOCKS5"), "socks5+tls://", ""},
|
|
{"http", nodeByName("HTTP"), "https://", ""},
|
|
{"wireguard", nodeByName("WG"), "wireguard://", "wg-privkey"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
uri := ToProxyUri(c.node)
|
|
if uri == "" {
|
|
t.Fatalf("ToProxyUri(%s) empty", c.name)
|
|
}
|
|
if !strings.HasPrefix(uri, c.scheme) {
|
|
t.Errorf("expected scheme %q, got: %s", c.scheme, uri)
|
|
}
|
|
if c.contains != "" && !strings.Contains(uri, c.contains) {
|
|
t.Errorf("expected %q in uri: %s", c.contains, uri)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriUnsupported(t *testing.T) {
|
|
node := model.ProxyNode{"type": "snell", "name": "x"}
|
|
if uri := ToProxyUri(node); uri != "" {
|
|
t.Errorf("expected empty for snell, got: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriVlessSecurityNone(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vless", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "tls": false,
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.Contains(uri, "security=none") {
|
|
t.Errorf("expected security=none: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriVlessSecurityTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vless", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "tls": true, "servername": "sni.com",
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.Contains(uri, "security=tls") {
|
|
t.Errorf("expected security=tls: %s", uri)
|
|
}
|
|
if !strings.Contains(uri, "sni=sni.com") {
|
|
t.Errorf("expected sni=sni.com: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriVlessRealitySpxFallback(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vless", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "tls": true,
|
|
"reality-opts": map[string]any{
|
|
"public-key": "pk",
|
|
"short-id": "sid",
|
|
// spider-x missing → spx should fallback to "/"
|
|
},
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.Contains(uri, "spx=%2F") { // url-encoded "/"
|
|
t.Errorf("expected spx=/ fallback: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriVlessFlow(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vless", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u", "tls": true, "flow": "xtls-rprx-vision",
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.Contains(uri, "flow=xtls-rprx-vision") {
|
|
t.Errorf("expected flow=xtls-rprx-vision: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriHysteria2Insecure(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "hysteria2", "name": "h", "server": "s", "port": 443,
|
|
"password": "p", "skip-cert-verify": true,
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.Contains(uri, "insecure=1") {
|
|
t.Errorf("expected insecure=1: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriHysteria2NoObfs(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "hysteria2", "name": "h", "server": "s", "port": 443,
|
|
"password": "p",
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if strings.Contains(uri, "obfs=") {
|
|
t.Errorf("expected no obfs param: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriTrojanAllowInsecure(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "trojan", "name": "t", "server": "s", "port": 443,
|
|
"password": "p", "skip-cert-verify": true,
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.Contains(uri, "allowInsecure=1") {
|
|
t.Errorf("expected allowInsecure=1: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriSocks5NoTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "socks5", "name": "s", "server": "s", "port": 1,
|
|
"tls": false,
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.HasPrefix(uri, "socks5://") {
|
|
t.Errorf("expected socks5:// (not socks5+tls): %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriSocks5WithAuth(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "socks5", "name": "s", "server": "s", "port": 1,
|
|
"username": "u", "password": "p", "tls": false,
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.Contains(uri, "u:p@") {
|
|
t.Errorf("expected auth in uri: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriHTTPNoTls(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "http", "name": "h", "server": "s", "port": 80, "tls": false,
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.HasPrefix(uri, "http://") {
|
|
t.Errorf("expected http://: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriHTTPWithAuth(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "http", "name": "h", "server": "s", "port": 80,
|
|
"username": "u", "password": "p",
|
|
}
|
|
uri := ToProxyUri(node)
|
|
if !strings.Contains(uri, "u:p@") {
|
|
t.Errorf("expected auth in uri: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriSsBase64(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "ss", "name": "x", "server": "s", "port": 1,
|
|
"cipher": "aes-256-gcm", "password": "p",
|
|
}
|
|
uri := ToProxyUri(node)
|
|
// ss://base64(cipher:password@server:port)#name
|
|
if !strings.HasPrefix(uri, "ss://") {
|
|
t.Fatalf("expected ss:// prefix: %s", uri)
|
|
}
|
|
parts := strings.SplitN(uri, "#", 2)
|
|
if len(parts) != 2 {
|
|
t.Fatalf("expected fragment in uri: %s", uri)
|
|
}
|
|
b64 := strings.TrimPrefix(parts[0], "ss://")
|
|
decoded, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
t.Fatalf("ss userinfo not valid base64: %v", err)
|
|
}
|
|
if !strings.Contains(string(decoded), "aes-256-gcm:p@s:1") {
|
|
t.Errorf("unexpected decoded ss userinfo: %s", decoded)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriSsr(t *testing.T) {
|
|
node := nodeByName("SSR")
|
|
uri := ToProxyUri(node)
|
|
if !strings.HasPrefix(uri, "ssr://") {
|
|
t.Fatalf("expected ssr:// prefix: %s", uri)
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriVmess(t *testing.T) {
|
|
node := nodeByName("VMess")
|
|
uri := ToProxyUri(node)
|
|
if !strings.HasPrefix(uri, "vmess://") {
|
|
t.Fatalf("expected vmess:// prefix: %s", uri)
|
|
}
|
|
b64 := strings.TrimPrefix(uri, "vmess://")
|
|
decoded, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
t.Fatalf("vmess payload not valid base64: %v", err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(decoded, &parsed); err != nil {
|
|
t.Fatalf("vmess payload not valid JSON: %v", err)
|
|
}
|
|
if parsed["v"] != "2" {
|
|
t.Errorf("expected v=2, got: %v", parsed["v"])
|
|
}
|
|
if parsed["add"] != "vmess.example.com" {
|
|
t.Errorf("expected add=vmess.example.com, got: %v", parsed["add"])
|
|
}
|
|
if parsed["net"] != "ws" {
|
|
t.Errorf("expected net=ws, got: %v", parsed["net"])
|
|
}
|
|
}
|
|
|
|
func TestExtraToProxyUriVmessNoPort(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vmess", "name": "v", "server": "s",
|
|
"uuid": "u",
|
|
}
|
|
uri := ToProxyUri(node)
|
|
b64 := strings.TrimPrefix(uri, "vmess://")
|
|
decoded, _ := base64.StdEncoding.DecodeString(b64)
|
|
var parsed map[string]any
|
|
json.Unmarshal(decoded, &parsed)
|
|
if parsed["port"] != "" {
|
|
t.Errorf("expected empty port for 0, got: %v", parsed["port"])
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderProxyUris(t *testing.T) {
|
|
nodes := allNodeTypes()
|
|
out := RenderProxyUris(nodes)
|
|
lines := strings.Split(out, "\n")
|
|
// 15 total nodes, but snell/ssh/h2-connect are not supported by URI renderer
|
|
// so we expect 12 URIs
|
|
wantLines := 12
|
|
if len(lines) != wantLines {
|
|
t.Errorf("expected %d lines, got %d", wantLines, len(lines))
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderProxyUrisWithUnsupported(t *testing.T) {
|
|
nodes := []model.ProxyNode{
|
|
{"type": "snell", "name": "x"}, // unsupported
|
|
{"type": "ss", "name": "y", "server": "s", "port": 1, "cipher": "c", "password": "p"},
|
|
}
|
|
out := RenderProxyUris(nodes)
|
|
if out == "" {
|
|
t.Fatal("expected non-empty output")
|
|
}
|
|
if strings.Contains(out, "snell") {
|
|
t.Errorf("snell should be filtered out: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestExtraSniStr(t *testing.T) {
|
|
cases := []struct {
|
|
proxy model.ProxyNode
|
|
keys []string
|
|
want string
|
|
}{
|
|
{model.ProxyNode{"sni": "a"}, []string{"sni", "servername"}, "a"},
|
|
{model.ProxyNode{"servername": "b"}, []string{"sni", "servername"}, "b"},
|
|
{model.ProxyNode{}, []string{"sni", "servername"}, ""},
|
|
{model.ProxyNode{"sni": ""}, []string{"sni"}, ""},
|
|
}
|
|
for _, c := range cases {
|
|
got := sniStr(c.proxy, c.keys...)
|
|
if got != c.want {
|
|
t.Errorf("sniStr(%v, %v) = %q, want %q", c.proxy, c.keys, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraAidStr(t *testing.T) {
|
|
cases := []struct {
|
|
in any
|
|
want string
|
|
}{
|
|
{nil, "0"},
|
|
{0, "0"},
|
|
{int64(64), "64"},
|
|
{float64(128), "128"},
|
|
{"", "0"},
|
|
{"256", "256"},
|
|
{true, "0"},
|
|
}
|
|
for _, c := range cases {
|
|
got := aidStr(c.in)
|
|
if got != c.want {
|
|
t.Errorf("aidStr(%v) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraBoolToStr(t *testing.T) {
|
|
if boolToStr(true, "yes", "no") != "yes" {
|
|
t.Error("boolToStr(true) failed")
|
|
}
|
|
if boolToStr(false, "yes", "no") != "no" {
|
|
t.Error("boolToStr(false) failed")
|
|
}
|
|
}
|
|
|
|
func TestExtraWsHeaderHostKey(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
wsOpts map[string]any
|
|
want string
|
|
}{
|
|
{"Host capital", map[string]any{"headers": map[string]any{"Host": "h.com"}}, "h.com"},
|
|
{"host lowercase", map[string]any{"headers": map[string]any{"host": "h.com"}}, "h.com"},
|
|
{"no headers", map[string]any{}, ""},
|
|
{"nil", nil, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := WsHeaderHostKey(c.wsOpts)
|
|
if got != c.want {
|
|
t.Errorf("WsHeaderHostKey(%v) = %q, want %q", c.wsOpts, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- textutil.go tests ---
|
|
|
|
func TestExtraRenderTextProxyList(t *testing.T) {
|
|
nodes := []model.ProxyNode{
|
|
{"type": "ss", "name": "a"},
|
|
{"type": "ss", "name": "b"},
|
|
}
|
|
producer := func(p model.ProxyNode) string {
|
|
return getString(p, "name")
|
|
}
|
|
out, err := RenderTextProxyList(nodes, "test", producer)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if out != "a\nb" {
|
|
t.Errorf("expected 'a\\nb', got: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTextProxyListSkipEmpty(t *testing.T) {
|
|
nodes := []model.ProxyNode{
|
|
{"type": "ss", "name": "a"},
|
|
{"type": "ss", "name": "skip"}, // producer returns ""
|
|
{"type": "ss", "name": "b"},
|
|
}
|
|
producer := func(p model.ProxyNode) string {
|
|
if getString(p, "name") == "skip" {
|
|
return ""
|
|
}
|
|
return getString(p, "name")
|
|
}
|
|
out, err := RenderTextProxyList(nodes, "test", producer)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if out != "a\nb" {
|
|
t.Errorf("expected 'a\\nb', got: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTextProxyListAllEmpty(t *testing.T) {
|
|
nodes := []model.ProxyNode{
|
|
{"type": "ss", "name": "a"},
|
|
{"type": "ss", "name": "b"},
|
|
}
|
|
producer := func(p model.ProxyNode) string { return "" }
|
|
_, err := RenderTextProxyList(nodes, "test", producer)
|
|
if err == nil {
|
|
t.Fatal("expected error when all producers return empty")
|
|
}
|
|
}
|
|
|
|
func TestExtraJoinTextProxy(t *testing.T) {
|
|
entries := [][2]any{
|
|
{"a", "1"},
|
|
{"b", "2"},
|
|
{"c", nil}, // should be dropped
|
|
{"d", ""}, // should be dropped
|
|
{"e", true},
|
|
{"f", false},
|
|
}
|
|
got := JoinTextProxy("base", entries)
|
|
want := "base,a=1,b=2,e=true,f=false"
|
|
if got != want {
|
|
t.Errorf("JoinTextProxy = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExtraJoinTextProxyNoEntries(t *testing.T) {
|
|
got := JoinTextProxy("base", nil)
|
|
if got != "base" {
|
|
t.Errorf("expected base only, got: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtraJoinTextProxyAllNilEntries(t *testing.T) {
|
|
entries := [][2]any{
|
|
{"a", nil},
|
|
{"b", ""},
|
|
}
|
|
got := JoinTextProxy("base", entries)
|
|
if got != "base" {
|
|
t.Errorf("expected base only when all entries nil/empty, got: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtraFormatTextOptionValue(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input any
|
|
want string
|
|
}{
|
|
{"bool_true", true, "true"},
|
|
{"bool_false", false, "false"},
|
|
{"nil", nil, ""},
|
|
{"int", 42, "42"},
|
|
{"string_plain", "hello", "hello"},
|
|
{"string_with_comma", "a,b", `"a,b"`},
|
|
{"string_with_space", "a b", `"a b"`},
|
|
{"string_with_quote", `a"b`, `"a\"b"`},
|
|
{"array_any", []any{1, 2, 3}, `"1,2,3"`},
|
|
{"array_string", []string{"a", "b"}, `"a,b"`},
|
|
{"array_empty", []any{}, `""`},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := FormatTextOptionValue(c.input)
|
|
if got != c.want {
|
|
t.Errorf("FormatTextOptionValue(%v) = %q, want %q", c.input, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraQuoteTextValue(t *testing.T) {
|
|
cases := []struct {
|
|
in any
|
|
want string
|
|
}{
|
|
{nil, `""`},
|
|
{"hello", `"hello"`},
|
|
{`a"b`, `"a\"b"`},
|
|
{42, `"42"`},
|
|
{"", `""`},
|
|
}
|
|
for _, c := range cases {
|
|
got := QuoteTextValue(c.in)
|
|
if got != c.want {
|
|
t.Errorf("QuoteTextValue(%v) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraSanitizeTextProxyName(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
{"name", "name"},
|
|
{"name=test", "name test"},
|
|
{"name,with,comma", "name with comma"},
|
|
{"name\r\n", "name"},
|
|
{"", "proxy"},
|
|
{" ", "proxy"},
|
|
{"=,\r\n", "proxy"},
|
|
{" name ", "name"},
|
|
}
|
|
for _, c := range cases {
|
|
got := SanitizeTextProxyName(c.in)
|
|
if got != c.want {
|
|
t.Errorf("SanitizeTextProxyName(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraCommonTextOptions(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"skip-cert-verify": true,
|
|
"udp": true,
|
|
"tfo": true,
|
|
"alpn": []any{"h2", "http/1.1"},
|
|
}
|
|
entries := CommonTextOptions(proxy)
|
|
m := make(map[string]any)
|
|
for _, e := range entries {
|
|
m[e[0].(string)] = e[1]
|
|
}
|
|
if m["skip-cert-verify"] != true {
|
|
t.Error("expected skip-cert-verify=true")
|
|
}
|
|
if m["udp-relay"] != true {
|
|
t.Error("expected udp-relay=true")
|
|
}
|
|
if m["fast-open"] != true {
|
|
t.Error("expected fast-open=true (from tfo)")
|
|
}
|
|
if m["alpn"] != "h2,http/1.1" {
|
|
t.Errorf("expected alpn=h2,http/1.1, got: %v", m["alpn"])
|
|
}
|
|
}
|
|
|
|
func TestExtraCommonTextOptionsFastOpenFallback(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"fast-open": true,
|
|
}
|
|
entries := CommonTextOptions(proxy)
|
|
m := make(map[string]any)
|
|
for _, e := range entries {
|
|
m[e[0].(string)] = e[1]
|
|
}
|
|
if m["fast-open"] != true {
|
|
t.Error("expected fast-open=true from fallback")
|
|
}
|
|
}
|
|
|
|
func TestExtraFastOpenValue(t *testing.T) {
|
|
// tfo takes priority over fast-open
|
|
if v := fastOpenValue(model.ProxyNode{"tfo": "x", "fast-open": "y"}); v != "x" {
|
|
t.Errorf("expected tfo=x to win, got: %v", v)
|
|
}
|
|
// falls back to fast-open
|
|
if v := fastOpenValue(model.ProxyNode{"fast-open": "y"}); v != "y" {
|
|
t.Errorf("expected fast-open=y, got: %v", v)
|
|
}
|
|
// both nil
|
|
if v := fastOpenValue(model.ProxyNode{}); v != nil {
|
|
t.Errorf("expected nil, got: %v", v)
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendWsOptionsSurge(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"network": "ws",
|
|
"ws-opts": map[string]any{
|
|
"path": "/ws",
|
|
"headers": map[string]any{"Host": "h.com"},
|
|
},
|
|
}
|
|
entries := AppendWsOptions([][2]any{}, proxy, "surge")
|
|
m := entriesToMap(entries)
|
|
if m["ws"] != true {
|
|
t.Error("expected ws=true")
|
|
}
|
|
if m["ws-path"] != "/ws" {
|
|
t.Errorf("expected ws-path=/ws, got: %v", m["ws-path"])
|
|
}
|
|
if m["ws-headers"] != "h.com" {
|
|
t.Errorf("expected ws-headers=h.com, got: %v", m["ws-headers"])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendWsOptionsNonSurge(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"network": "ws",
|
|
"ws-opts": map[string]any{"path": "/ws", "headers": map[string]any{"Host": "h"}},
|
|
}
|
|
entries := AppendWsOptions([][2]any{}, proxy, "loon")
|
|
m := entriesToMap(entries)
|
|
if _, ok := m["ws"]; ok {
|
|
t.Error("expected no ws=true for non-surge")
|
|
}
|
|
if m["path"] != "/ws" {
|
|
t.Errorf("expected path=/ws, got: %v", m["path"])
|
|
}
|
|
if m["host"] != "h" {
|
|
t.Errorf("expected host=h, got: %v", m["host"])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendWsOptionsNonWs(t *testing.T) {
|
|
proxy := model.ProxyNode{"network": "tcp"}
|
|
entries := AppendWsOptions([][2]any{{"x", "y"}}, proxy, "surge")
|
|
if len(entries) != 1 {
|
|
t.Errorf("expected unchanged entries for non-ws, got: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendWsOptionsDefaultPath(t *testing.T) {
|
|
proxy := model.ProxyNode{"network": "ws"}
|
|
entries := AppendWsOptions([][2]any{}, proxy, "surge")
|
|
m := entriesToMap(entries)
|
|
if m["ws-path"] != "/" {
|
|
t.Errorf("expected default ws-path=/, got: %v", m["ws-path"])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendPluginOptionsSurge(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"plugin": "obfs",
|
|
"plugin-opts": map[string]any{
|
|
"mode": "http", "host": "h.com", "path": "/p",
|
|
},
|
|
}
|
|
entries := AppendPluginOptions([][2]any{}, proxy, "surge")
|
|
m := entriesToMap(entries)
|
|
if m["obfs"] != "http" {
|
|
t.Errorf("expected obfs=http, got: %v", m["obfs"])
|
|
}
|
|
if m["obfs-host"] != "h.com" {
|
|
t.Errorf("expected obfs-host=h.com, got: %v", m["obfs-host"])
|
|
}
|
|
if m["obfs-uri"] != "/p" {
|
|
t.Errorf("expected obfs-uri=/p, got: %v", m["obfs-uri"])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendPluginOptionsNonSurge(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"plugin": "obfs",
|
|
"plugin-opts": map[string]any{
|
|
"mode": "http", "host": "h.com", "path": "/p",
|
|
},
|
|
}
|
|
entries := AppendPluginOptions([][2]any{}, proxy, "loon")
|
|
m := entriesToMap(entries)
|
|
if m["obfs-name"] != "http" {
|
|
t.Errorf("expected obfs-name=http, got: %v", m["obfs-name"])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendPluginOptionsNonObfs(t *testing.T) {
|
|
proxy := model.ProxyNode{"plugin": "other"}
|
|
entries := AppendPluginOptions([][2]any{{"x", "y"}}, proxy, "surge")
|
|
if len(entries) != 1 {
|
|
t.Errorf("expected unchanged entries for non-obfs, got: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendPluginOptionsNoOpts(t *testing.T) {
|
|
proxy := model.ProxyNode{"plugin": "obfs"}
|
|
entries := AppendPluginOptions([][2]any{{"x", "y"}}, proxy, "surge")
|
|
if len(entries) != 1 {
|
|
t.Errorf("expected unchanged entries when no plugin-opts, got: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendRealityOptions(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"reality-opts": map[string]any{
|
|
"public-key": "pk",
|
|
"short-id": "sid",
|
|
},
|
|
}
|
|
entries := AppendRealityOptions([][2]any{}, proxy)
|
|
m := entriesToMap(entries)
|
|
if m["public-key"] != "pk" {
|
|
t.Errorf("expected public-key=pk, got: %v", m["public-key"])
|
|
}
|
|
if m["short-id"] != "sid" {
|
|
t.Errorf("expected short-id=sid, got: %v", m["short-id"])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendRealityOptionsNoOpts(t *testing.T) {
|
|
proxy := model.ProxyNode{}
|
|
entries := AppendRealityOptions([][2]any{}, proxy)
|
|
// AppendRealityOptions always appends entries, even when reality-opts is nil.
|
|
// The nil values would be dropped by JoinTextProxy, but the entries exist.
|
|
if len(entries) != 2 {
|
|
t.Fatalf("expected 2 entries (public-key, short-id), got %d", len(entries))
|
|
}
|
|
if entries[0][0] != "public-key" || entries[0][1] != nil {
|
|
t.Errorf("expected public-key=nil, got: %v", entries[0])
|
|
}
|
|
if entries[1][0] != "short-id" || entries[1][1] != nil {
|
|
t.Errorf("expected short-id=nil, got: %v", entries[1])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendQxRealityOptions(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"reality-opts": map[string]any{
|
|
"public-key": "pk",
|
|
"short-id": "sid",
|
|
},
|
|
}
|
|
entries := AppendQxRealityOptions([][2]any{}, proxy)
|
|
m := entriesToMap(entries)
|
|
if m["reality-base64-pubkey"] != "pk" {
|
|
t.Errorf("expected reality-base64-pubkey=pk, got: %v", m["reality-base64-pubkey"])
|
|
}
|
|
if m["reality-hex-shortid"] != "sid" {
|
|
t.Errorf("expected reality-hex-shortid=sid, got: %v", m["reality-hex-shortid"])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendQxObfs(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"plugin": "obfs",
|
|
"plugin-opts": map[string]any{
|
|
"mode": "http", "host": "h.com", "path": "/p",
|
|
},
|
|
}
|
|
entries := AppendQxObfs([][2]any{}, proxy)
|
|
m := entriesToMap(entries)
|
|
if m["obfs"] != "http" {
|
|
t.Errorf("expected obfs=http, got: %v", m["obfs"])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendQxObfsNonObfs(t *testing.T) {
|
|
proxy := model.ProxyNode{"plugin": "other"}
|
|
entries := AppendQxObfs([][2]any{{"x", "y"}}, proxy)
|
|
if len(entries) != 1 {
|
|
t.Errorf("expected unchanged for non-obfs, got: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendQxObfsNoOpts(t *testing.T) {
|
|
proxy := model.ProxyNode{"plugin": "obfs"}
|
|
entries := AppendQxObfs([][2]any{{"x", "y"}}, proxy)
|
|
if len(entries) != 1 {
|
|
t.Errorf("expected unchanged when no plugin-opts, got: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendQxTransport(t *testing.T) {
|
|
proxy := model.ProxyNode{
|
|
"network": "ws",
|
|
"tls": true,
|
|
"ws-opts": map[string]any{
|
|
"path": "/ws",
|
|
"headers": map[string]any{"Host": "h.com"},
|
|
},
|
|
}
|
|
entries := AppendQxTransport([][2]any{}, proxy)
|
|
m := entriesToMap(entries)
|
|
if m["obfs"] != "wss" {
|
|
t.Errorf("expected obfs=wss, got: %v", m["obfs"])
|
|
}
|
|
if m["obfs-uri"] != "/ws" {
|
|
t.Errorf("expected obfs-uri=/ws, got: %v", m["obfs-uri"])
|
|
}
|
|
}
|
|
|
|
func TestExtraAppendQxTransportNonWs(t *testing.T) {
|
|
proxy := model.ProxyNode{"network": "tcp"}
|
|
entries := AppendQxTransport([][2]any{{"x", "y"}}, proxy)
|
|
if len(entries) != 1 {
|
|
t.Errorf("expected unchanged for non-ws, got: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestExtraWsHeaderHost(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
wsOpts map[string]any
|
|
want string
|
|
}{
|
|
{"Host capital", map[string]any{"headers": map[string]any{"Host": "h.com"}}, "h.com"},
|
|
{"host lowercase", map[string]any{"headers": map[string]any{"host": "h.com"}}, "h.com"},
|
|
{"no headers", map[string]any{}, ""},
|
|
{"nil", nil, ""},
|
|
{"headers not map", map[string]any{"headers": "not-a-map"}, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := WsHeaderHost(c.wsOpts)
|
|
if got != c.want {
|
|
t.Errorf("WsHeaderHost(%v) = %q, want %q", c.wsOpts, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraFormatAlpn(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input any
|
|
want string
|
|
}{
|
|
{"nil", nil, ""},
|
|
{"array_any", []any{"h2", "http/1.1"}, "h2,http/1.1"},
|
|
{"array_any_with_empty", []any{"h2", "", "http/1.1"}, "h2,http/1.1"},
|
|
{"array_string", []string{"h2", "http/1.1"}, "h2,http/1.1"},
|
|
{"array_string_with_empty", []string{"h2", "", "http/1.1"}, "h2,http/1.1"},
|
|
{"string", "h2", "h2"},
|
|
{"int", 42, "42"},
|
|
{"empty_array", []any{}, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := FormatAlpn(c.input)
|
|
if got != c.want {
|
|
t.Errorf("FormatAlpn(%v) = %q, want %q", c.input, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraGetString(t *testing.T) {
|
|
proxy := model.ProxyNode{"key": "value", "num": 42}
|
|
if getString(proxy, "key") != "value" {
|
|
t.Error("getString failed for string")
|
|
}
|
|
if getString(proxy, "num") != "" {
|
|
t.Error("getString should return empty for non-string")
|
|
}
|
|
if getString(proxy, "missing") != "" {
|
|
t.Error("getString should return empty for missing key")
|
|
}
|
|
}
|
|
|
|
func TestExtraGetBool(t *testing.T) {
|
|
cases := []struct {
|
|
input any
|
|
want bool
|
|
}{
|
|
{true, true},
|
|
{false, false},
|
|
{"true", true},
|
|
{"1", true},
|
|
{"false", false},
|
|
{"0", false},
|
|
{42, false},
|
|
{nil, false},
|
|
}
|
|
for _, c := range cases {
|
|
proxy := model.ProxyNode{"key": c.input}
|
|
got := getBool(proxy, "key")
|
|
if got != c.want {
|
|
t.Errorf("getBool(%v) = %v, want %v", c.input, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraGetInt(t *testing.T) {
|
|
cases := []struct {
|
|
input any
|
|
want int
|
|
}{
|
|
{42, 42},
|
|
{int64(64), 64},
|
|
{float64(128.0), 128},
|
|
{"256", 256},
|
|
{"not-a-num", 0},
|
|
{nil, 0},
|
|
{true, 0},
|
|
}
|
|
for _, c := range cases {
|
|
proxy := model.ProxyNode{"key": c.input}
|
|
got := getInt(proxy, "key")
|
|
if got != c.want {
|
|
t.Errorf("getInt(%v) = %d, want %d", c.input, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraStringSetting(t *testing.T) {
|
|
if stringSetting(nil) != "" {
|
|
t.Error("expected empty for nil")
|
|
}
|
|
if stringSetting("hello") != "hello" {
|
|
t.Error("expected hello")
|
|
}
|
|
if stringSetting(42) != "42" {
|
|
t.Error("expected 42")
|
|
}
|
|
}
|
|
|
|
func TestExtraNumberOrUndefined(t *testing.T) {
|
|
cases := []struct {
|
|
input any
|
|
want int
|
|
}{
|
|
{nil, 0},
|
|
{42, 42},
|
|
{int64(64), 64},
|
|
{float64(128.0), 128},
|
|
{"", 0},
|
|
{"256", 256},
|
|
{"not-a-num", 0},
|
|
{true, 0},
|
|
}
|
|
for _, c := range cases {
|
|
got := numberOrUndefined(c.input)
|
|
if got != c.want {
|
|
t.Errorf("numberOrUndefined(%v) = %d, want %d", c.input, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraHasValue(t *testing.T) {
|
|
cases := []struct {
|
|
input any
|
|
want bool
|
|
}{
|
|
{nil, false},
|
|
{"", false},
|
|
{"hello", true},
|
|
{42, true},
|
|
{true, true},
|
|
{false, true}, // false is not nil or empty-string
|
|
}
|
|
for _, c := range cases {
|
|
got := hasValue(c.input)
|
|
if got != c.want {
|
|
t.Errorf("hasValue(%v) = %v, want %v", c.input, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- surge.go helpers ---
|
|
|
|
func TestExtraItoa(t *testing.T) {
|
|
cases := []struct {
|
|
in int
|
|
want string
|
|
}{
|
|
{0, "0"},
|
|
{42, "42"},
|
|
{-42, "-42"},
|
|
{123456, "123456"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := itoa(c.in); got != c.want {
|
|
t.Errorf("itoa(%d) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraUnshift(t *testing.T) {
|
|
base := [][2]any{{"a", 1}, {"b", 2}}
|
|
front := [][2]any{{"x", 9}, {"y", 8}}
|
|
got := unshift(base, front...)
|
|
if len(got) != 4 {
|
|
t.Fatalf("expected 4 entries, got %d", len(got))
|
|
}
|
|
if got[0][0] != "x" || got[1][0] != "y" || got[2][0] != "a" || got[3][0] != "b" {
|
|
t.Errorf("unshift order wrong: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestExtraStrOr(t *testing.T) {
|
|
proxy := model.ProxyNode{"cipher": "aes-256-gcm"}
|
|
if strOr(proxy, "cipher", "default") != "aes-256-gcm" {
|
|
t.Error("expected cipher value")
|
|
}
|
|
if strOr(proxy, "missing", "default") != "default" {
|
|
t.Error("expected default for missing key")
|
|
}
|
|
proxy2 := model.ProxyNode{"cipher": ""}
|
|
if strOr(proxy2, "cipher", "default") != "default" {
|
|
t.Error("expected default for empty string")
|
|
}
|
|
}
|
|
|
|
func TestExtraSniOr(t *testing.T) {
|
|
proxy := model.ProxyNode{"sni": "a", "servername": "b"}
|
|
if sniOr(proxy, "sni", "servername") != "a" {
|
|
t.Error("expected first key")
|
|
}
|
|
proxy2 := model.ProxyNode{"servername": "b"}
|
|
if sniOr(proxy2, "sni", "servername") != "b" {
|
|
t.Error("expected second key")
|
|
}
|
|
if sniOr(model.ProxyNode{}, "sni", "servername") != nil {
|
|
t.Error("expected nil when none present")
|
|
}
|
|
}
|
|
|
|
func TestExtraPskOrPassword(t *testing.T) {
|
|
proxy := model.ProxyNode{"psk": "psk-val"}
|
|
if pskOrPassword(proxy) != "psk-val" {
|
|
t.Error("expected psk value")
|
|
}
|
|
proxy2 := model.ProxyNode{"password": "pass-val"}
|
|
if pskOrPassword(proxy2) != "pass-val" {
|
|
t.Error("expected password value as fallback")
|
|
}
|
|
if pskOrPassword(model.ProxyNode{}) != nil {
|
|
t.Error("expected nil when neither present")
|
|
}
|
|
}
|
|
|
|
func TestExtraVersionOr3(t *testing.T) {
|
|
proxy := model.ProxyNode{"version": 4}
|
|
if versionOr3(proxy) != 4 {
|
|
t.Error("expected version 4")
|
|
}
|
|
if versionOr3(model.ProxyNode{}) != 3 {
|
|
t.Error("expected default 3")
|
|
}
|
|
}
|
|
|
|
func TestExtraSurgeType(t *testing.T) {
|
|
cases := []struct {
|
|
proxy model.ProxyNode
|
|
want string
|
|
}{
|
|
{model.ProxyNode{"type": "socks5", "tls": true}, "socks5-tls"},
|
|
{model.ProxyNode{"type": "socks5", "tls": false}, "socks5"},
|
|
{model.ProxyNode{"type": "http", "tls": true}, "https"},
|
|
{model.ProxyNode{"type": "http", "tls": false}, "http"},
|
|
{model.ProxyNode{"type": "vmess"}, "vmess"},
|
|
{model.ProxyNode{"type": "trojan"}, "trojan"},
|
|
}
|
|
for _, c := range cases {
|
|
got := surgeType(c.proxy)
|
|
if got != c.want {
|
|
t.Errorf("surgeType(%v) = %q, want %q", c.proxy, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- mihomo.go tests ---
|
|
|
|
func TestExtraRenderMihomoYamlDefault(t *testing.T) {
|
|
nodes := allNodeTypes()[:2]
|
|
out := RenderMihomoYaml(nodes, "https://sub.example.com/api", nil)
|
|
var parsed map[string]any
|
|
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("not valid YAML: %v\n%s", err, out)
|
|
}
|
|
if parsed["mixed-port"] != 7890 {
|
|
t.Errorf("expected default mixed-port=7890, got: %v", parsed["mixed-port"])
|
|
}
|
|
if parsed["mode"] != "rule" {
|
|
t.Errorf("expected default mode=rule, got: %v", parsed["mode"])
|
|
}
|
|
if parsed["log-level"] != "info" {
|
|
t.Errorf("expected default log-level=info, got: %v", parsed["log-level"])
|
|
}
|
|
if parsed["allow-lan"] != false {
|
|
t.Errorf("expected default allow-lan=false, got: %v", parsed["allow-lan"])
|
|
}
|
|
// Note: DefaultProxyGroups uses []string for "proxies" but ExpandGroupProxies
|
|
// expects []any, so $all is never expanded and default groups produce empty
|
|
// proxy lists. This is a known source-code limitation; we verify the document
|
|
// still has the proxy-groups key present.
|
|
if _, ok := parsed["proxy-groups"]; !ok {
|
|
t.Error("expected proxy-groups key in output")
|
|
}
|
|
rules, _ := parsed["rules"].([]any)
|
|
if len(rules) != 1 || rules[0] != "MATCH,🚀 节点选择" {
|
|
t.Errorf("expected default MATCH rule, got: %v", rules)
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderMihomoYamlWithTemplate(t *testing.T) {
|
|
nodes := allNodeTypes()[:3]
|
|
tmpl := map[string]any{
|
|
"mixed-port": 7891,
|
|
"allow-lan": true,
|
|
"mode": "global",
|
|
"log-level": "debug",
|
|
"dns": map[string]any{"enable": true},
|
|
"sniffer": map[string]any{"enable": true},
|
|
"rules": []any{
|
|
"GEOIP,CN,DIRECT",
|
|
"MATCH,PROXY",
|
|
},
|
|
}
|
|
out := RenderMihomoYaml(nodes, "https://sub.example.com/api", tmpl)
|
|
var parsed map[string]any
|
|
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("not valid YAML: %v\n%s", err, out)
|
|
}
|
|
if parsed["mixed-port"] != 7891 {
|
|
t.Errorf("expected mixed-port=7891, got: %v", parsed["mixed-port"])
|
|
}
|
|
if parsed["allow-lan"] != true {
|
|
t.Errorf("expected allow-lan=true, got: %v", parsed["allow-lan"])
|
|
}
|
|
if parsed["mode"] != "global" {
|
|
t.Errorf("expected mode=global, got: %v", parsed["mode"])
|
|
}
|
|
if parsed["log-level"] != "debug" {
|
|
t.Errorf("expected log-level=debug, got: %v", parsed["log-level"])
|
|
}
|
|
if _, ok := parsed["dns"]; !ok {
|
|
t.Error("expected dns in output")
|
|
}
|
|
if _, ok := parsed["sniffer"]; !ok {
|
|
t.Error("expected sniffer in output")
|
|
}
|
|
rules, _ := parsed["rules"].([]any)
|
|
if len(rules) != 2 {
|
|
t.Errorf("expected 2 rules, got %d", len(rules))
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderMihomoYamlRuleProviders(t *testing.T) {
|
|
nodes := allNodeTypes()[:1]
|
|
tmpl := map[string]any{
|
|
"rule-providers": map[string]any{
|
|
"reject": map[string]any{"type": "http", "url": "x"},
|
|
},
|
|
}
|
|
out := RenderMihomoYaml(nodes, "https://x", tmpl)
|
|
var parsed map[string]any
|
|
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("not valid YAML: %v", err)
|
|
}
|
|
if _, ok := parsed["rule-providers"]; !ok {
|
|
t.Error("expected rule-providers in output")
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderMihomoYamlCamelCaseKeys(t *testing.T) {
|
|
// Test camelCase variants (mixedPort, allowLan, logLevel)
|
|
tmpl := map[string]any{
|
|
"mixedPort": 7892,
|
|
"allowLan": true,
|
|
"logLevel": "warning",
|
|
}
|
|
nodes := allNodeTypes()[:1]
|
|
out := RenderMihomoYaml(nodes, "https://x", tmpl)
|
|
var parsed map[string]any
|
|
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("not valid YAML: %v", err)
|
|
}
|
|
if parsed["mixed-port"] != 7892 {
|
|
t.Errorf("expected mixed-port=7892 from mixedPort, got: %v", parsed["mixed-port"])
|
|
}
|
|
if parsed["allow-lan"] != true {
|
|
t.Errorf("expected allow-lan=true from allowLan, got: %v", parsed["allow-lan"])
|
|
}
|
|
if parsed["log-level"] != "warning" {
|
|
t.Errorf("expected log-level=warning from logLevel, got: %v", parsed["log-level"])
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderMihomoYamlCustomProxyGroups(t *testing.T) {
|
|
nodes := allNodeTypes()[:3]
|
|
tmpl := map[string]any{
|
|
"proxy-groups": []any{
|
|
map[string]any{
|
|
"name": "PROXY",
|
|
"type": "select",
|
|
"proxies": []any{"SS-Node", "VMess-Node", "DIRECT"},
|
|
},
|
|
map[string]any{
|
|
"name": "AUTO",
|
|
"type": "url-test",
|
|
"filter": ".*",
|
|
"url": "http://test",
|
|
},
|
|
},
|
|
}
|
|
out := RenderMihomoYaml(nodes, "https://x", tmpl)
|
|
var parsed map[string]any
|
|
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("not valid YAML: %v\n%s", err, out)
|
|
}
|
|
groups, _ := parsed["proxy-groups"].([]any)
|
|
if len(groups) != 2 {
|
|
t.Fatalf("expected 2 groups, got %d", len(groups))
|
|
}
|
|
first, _ := groups[0].(map[string]any)
|
|
if first["name"] != "PROXY" {
|
|
t.Errorf("expected first group PROXY, got: %v", first["name"])
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderMihomoYamlHeaderComment(t *testing.T) {
|
|
out := RenderMihomoYaml(allNodeTypes()[:1], "https://sub.example.com/api/path", nil)
|
|
if !strings.HasPrefix(out, "# Generated by Sub-Store") {
|
|
t.Errorf("expected header comment, got: %s", out[:50])
|
|
}
|
|
if !strings.Contains(out, "# Source: /api/path") {
|
|
t.Errorf("expected source path comment, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestExtraDefaultProxyGroups(t *testing.T) {
|
|
groups := DefaultProxyGroups()
|
|
if len(groups) != 3 {
|
|
t.Fatalf("expected 3 default groups, got %d", len(groups))
|
|
}
|
|
if groups[0]["name"] != "🚀 节点选择" {
|
|
t.Errorf("expected first group 🚀 节点选择, got: %v", groups[0]["name"])
|
|
}
|
|
if groups[1]["type"] != "url-test" {
|
|
t.Errorf("expected second group url-test, got: %v", groups[1]["type"])
|
|
}
|
|
}
|
|
|
|
// --- mihomo: ExpandGroupProxies & RenderTemplateProxyGroups ---
|
|
|
|
func TestExtraExpandGroupProxiesAll(t *testing.T) {
|
|
group := map[string]any{
|
|
"proxies": []any{"$all"},
|
|
}
|
|
nodeNames := []string{"A", "B", "C"}
|
|
got := ExpandGroupProxies(group, nodeNames)
|
|
if !reflect.DeepEqual(got, nodeNames) {
|
|
t.Errorf("ExpandGroupProxies $all = %v, want %v", got, nodeNames)
|
|
}
|
|
}
|
|
|
|
func TestExtraExpandGroupProxiesExplicitNames(t *testing.T) {
|
|
group := map[string]any{
|
|
"proxies": []any{"X", "Y", "$all", "Z"},
|
|
}
|
|
nodeNames := []string{"A", "B"}
|
|
got := ExpandGroupProxies(group, nodeNames)
|
|
want := []string{"X", "Y", "A", "B", "Z"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("ExpandGroupProxies = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExtraExpandGroupProxiesFilter(t *testing.T) {
|
|
group := map[string]any{
|
|
"filter": "HK.*",
|
|
}
|
|
nodeNames := []string{"HK-1", "US-1", "HK-2", "JP-1"}
|
|
got := ExpandGroupProxies(group, nodeNames)
|
|
want := []string{"HK-1", "HK-2"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("ExpandGroupProxies filter = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExtraExpandGroupProxiesFilterCaseInsensitive(t *testing.T) {
|
|
// Note: compileRegex strips "(?i)" prefix and prepends "i" as a flag,
|
|
// but Go regexp expects "(?i)" syntax not a bare flag prefix.
|
|
// So "(?i)hk.*" compiles to "ihk.*" which matches nothing.
|
|
// This test verifies the actual (buggy) behavior.
|
|
group := map[string]any{
|
|
"filter": "(?i)hk.*",
|
|
}
|
|
nodeNames := []string{"hk-1", "HK-2", "us-1"}
|
|
got := ExpandGroupProxies(group, nodeNames)
|
|
// Due to the bug, no names match
|
|
if len(got) != 0 {
|
|
t.Errorf("expected 0 matches due to (?i) handling bug, got: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestExtraExpandGroupProxiesFilterInvalid(t *testing.T) {
|
|
group := map[string]any{
|
|
"filter": "[invalid",
|
|
}
|
|
got := ExpandGroupProxies(group, []string{"A"})
|
|
if len(got) != 0 {
|
|
t.Errorf("expected empty for invalid regex, got: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestExtraExpandGroupProxiesFilterAndProxies(t *testing.T) {
|
|
group := map[string]any{
|
|
"filter": "HK.*",
|
|
"proxies": []any{"DIRECT", "$all"},
|
|
}
|
|
nodeNames := []string{"HK-1", "US-1"}
|
|
got := ExpandGroupProxies(group, nodeNames)
|
|
// filter results first, then proxies
|
|
want := []string{"HK-1", "DIRECT", "HK-1", "US-1"}
|
|
// uniqueStrings will dedupe
|
|
if !reflect.DeepEqual(got, want) {
|
|
// Due to uniqueStrings, HK-1 appears once
|
|
want = []string{"HK-1", "DIRECT", "US-1"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("ExpandGroupProxies filter+proxies = %v, want %v", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraExpandGroupProxiesEmptyStrings(t *testing.T) {
|
|
group := map[string]any{
|
|
"proxies": []any{"", "A", "", "B"},
|
|
}
|
|
got := ExpandGroupProxies(group, []string{})
|
|
want := []string{"A", "B"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("ExpandGroupProxies empty strings = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTemplateProxyGroupsFiltersEmpty(t *testing.T) {
|
|
proxies := allNodeTypes()[:2]
|
|
groups := []map[string]any{
|
|
{
|
|
"name": "Empty",
|
|
"type": "select",
|
|
"proxies": []any{"NONEXISTENT"},
|
|
},
|
|
{
|
|
"name": "Real",
|
|
"type": "select",
|
|
"proxies": []any{"SS-Node", "VMess-Node"},
|
|
},
|
|
}
|
|
result := RenderTemplateProxyGroups(proxies, groups)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 group (empty filtered), got %d", len(result))
|
|
}
|
|
if result[0]["name"] != "Real" {
|
|
t.Errorf("expected Real group, got: %v", result[0]["name"])
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTemplateProxyGroupsDeduplicates(t *testing.T) {
|
|
proxies := allNodeTypes()[:2]
|
|
groups := []map[string]any{
|
|
{
|
|
"name": "Test",
|
|
"type": "select",
|
|
"proxies": []any{"SS-Node", "SS-Node", "VMess-Node"},
|
|
},
|
|
}
|
|
result := RenderTemplateProxyGroups(proxies, groups)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 group, got %d", len(result))
|
|
}
|
|
proxiesList, _ := result[0]["proxies"].([]string)
|
|
if len(proxiesList) != 2 {
|
|
t.Errorf("expected 2 unique proxies, got %d: %v", len(proxiesList), proxiesList)
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTemplateProxyGroupsKeepsAllowedLiterals(t *testing.T) {
|
|
proxies := allNodeTypes()[:1]
|
|
groups := []map[string]any{
|
|
{
|
|
"name": "Test",
|
|
"type": "select",
|
|
"proxies": []any{"SS-Node", "DIRECT", "REJECT", "PASS"},
|
|
},
|
|
}
|
|
result := RenderTemplateProxyGroups(proxies, groups)
|
|
proxiesList, _ := result[0]["proxies"].([]string)
|
|
if len(proxiesList) != 4 {
|
|
t.Errorf("expected 4 entries (node+3 literals), got %d: %v", len(proxiesList), proxiesList)
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTemplateProxyGroupsStripsFilterAndEmpty(t *testing.T) {
|
|
proxies := allNodeTypes()[:2]
|
|
groups := []map[string]any{
|
|
{
|
|
"name": "Test",
|
|
"type": "select",
|
|
"filter": ".*",
|
|
"proxies": []any{"SS-Node"},
|
|
"extra": "",
|
|
"url": "http://test",
|
|
},
|
|
}
|
|
result := RenderTemplateProxyGroups(proxies, groups)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 group, got %d", len(result))
|
|
}
|
|
if _, ok := result[0]["filter"]; ok {
|
|
t.Error("filter should be stripped from output")
|
|
}
|
|
if _, ok := result[0]["extra"]; ok {
|
|
t.Error("empty string extra should be stripped")
|
|
}
|
|
if result[0]["url"] != "http://test" {
|
|
t.Error("url should be preserved")
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTemplateProxyGroupsCrossGroupRef(t *testing.T) {
|
|
proxies := allNodeTypes()[:1]
|
|
groups := []map[string]any{
|
|
{
|
|
"name": "Main",
|
|
"type": "select",
|
|
"proxies": []any{"SS-Node", "Sub"},
|
|
},
|
|
{
|
|
"name": "Sub",
|
|
"type": "select",
|
|
"proxies": []any{"SS-Node"},
|
|
},
|
|
}
|
|
result := RenderTemplateProxyGroups(proxies, groups)
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 groups, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// --- mihomo helpers ---
|
|
|
|
func TestExtraExtractGroupTemplates(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cfg map[string]any
|
|
want int
|
|
}{
|
|
{"proxyGroups camelCase", map[string]any{"proxyGroups": []any{map[string]any{"name": "x"}}}, 1},
|
|
{"proxy-groups kebab", map[string]any{"proxy-groups": []any{map[string]any{"name": "x"}}}, 1},
|
|
{"none", map[string]any{}, 0},
|
|
{"invalid type", map[string]any{"proxyGroups": "not-an-array"}, 0},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := extractGroupTemplates(c.cfg)
|
|
if len(got) != c.want {
|
|
t.Errorf("extractGroupTemplates = %d groups, want %d", len(got), c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtraFirstStr(t *testing.T) {
|
|
cfg := map[string]any{"a": "x", "b": ""}
|
|
if firstStr(cfg, "a", "b") != "x" {
|
|
t.Error("expected x")
|
|
}
|
|
if firstStr(cfg, "b", "c") != "" {
|
|
t.Error("expected empty")
|
|
}
|
|
if firstStr(cfg, "missing") != "" {
|
|
t.Error("expected empty for missing")
|
|
}
|
|
}
|
|
|
|
func TestExtraFirstInt(t *testing.T) {
|
|
cfg := map[string]any{"a": 42, "b": int64(64), "c": float64(128)}
|
|
if firstInt(cfg, "a") != 42 {
|
|
t.Error("expected 42")
|
|
}
|
|
if firstInt(cfg, "b") != 64 {
|
|
t.Error("expected 64")
|
|
}
|
|
if firstInt(cfg, "c") != 128 {
|
|
t.Error("expected 128")
|
|
}
|
|
if firstInt(cfg, "missing") != 0 {
|
|
t.Error("expected 0 for missing")
|
|
}
|
|
}
|
|
|
|
func TestExtraFirstBool(t *testing.T) {
|
|
cfg := map[string]any{"a": true, "b": false}
|
|
if !firstBool(cfg, "a") {
|
|
t.Error("expected true")
|
|
}
|
|
if firstBool(cfg, "b") {
|
|
t.Error("expected false")
|
|
}
|
|
if firstBool(cfg, "missing") {
|
|
t.Error("expected false for missing")
|
|
}
|
|
if firstBool(cfg, "x", "y") {
|
|
t.Error("expected false for all missing")
|
|
}
|
|
}
|
|
|
|
func TestExtraFirstAny(t *testing.T) {
|
|
cfg := map[string]any{"a": "value"}
|
|
if firstAny(cfg, "a") != "value" {
|
|
t.Error("expected value")
|
|
}
|
|
if firstAny(cfg, "missing") != nil {
|
|
t.Error("expected nil for missing")
|
|
}
|
|
if firstAny(cfg, "nilkey") != nil {
|
|
t.Error("expected nil")
|
|
}
|
|
}
|
|
|
|
func TestExtraContainsString(t *testing.T) {
|
|
list := []string{"a", "b", "c"}
|
|
if !containsString(list, "b") {
|
|
t.Error("expected true for b")
|
|
}
|
|
if containsString(list, "z") {
|
|
t.Error("expected false for z")
|
|
}
|
|
if containsString([]string{}, "a") {
|
|
t.Error("expected false for empty list")
|
|
}
|
|
}
|
|
|
|
func TestExtraUniqueStrings(t *testing.T) {
|
|
input := []string{"a", "b", "a", "", "c", "b"}
|
|
got := uniqueStrings(input)
|
|
want := []string{"a", "b", "c"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("uniqueStrings = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExtraFindNamesByRegex(t *testing.T) {
|
|
names := []string{"HK-1", "US-1", "HK-2"}
|
|
got := findNamesByRegex(names, "HK.*")
|
|
want := []string{"HK-1", "HK-2"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("findNamesByRegex = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExtraFindNamesByRegexInvalid(t *testing.T) {
|
|
got := findNamesByRegex([]string{"a"}, "[invalid")
|
|
if got != nil {
|
|
t.Errorf("expected nil for invalid regex, got: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestExtraCompileRegex(t *testing.T) {
|
|
cases := []struct {
|
|
input string
|
|
nil bool
|
|
}{
|
|
{"abc", false},
|
|
{"(?i)abc", false},
|
|
{"[invalid", true},
|
|
}
|
|
for _, c := range cases {
|
|
re := compileRegex(c.input)
|
|
if c.nil && re != nil {
|
|
t.Errorf("expected nil for %q", c.input)
|
|
}
|
|
if !c.nil && re == nil {
|
|
t.Errorf("expected non-nil for %q", c.input)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtraCompileRegexCaseInsensitive(t *testing.T) {
|
|
re := compileRegex("(?i)abc")
|
|
if re == nil {
|
|
t.Fatal("expected non-nil regex")
|
|
}
|
|
// Note: compileRegex strips "(?i)" and prepends "i" as a bare flag,
|
|
// which Go regexp interprets as part of the pattern, not as a flag.
|
|
// So "(?i)abc" becomes "iabc" which does NOT match "ABC".
|
|
// This test verifies the actual (buggy) behavior.
|
|
if re.MatchString("iabc") {
|
|
// "iabc" matches because the pattern is literally "iabc"
|
|
} else {
|
|
t.Error("expected pattern 'iabc' to match string 'iabc'")
|
|
}
|
|
}
|
|
|
|
// --- json.go tests ---
|
|
|
|
func TestExtraRenderJsonStripsNil(t *testing.T) {
|
|
nodes := []model.ProxyNode{
|
|
{"type": "ss", "name": "x", "server": nil, "port": 1},
|
|
}
|
|
out := RenderJson(nodes)
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("invalid JSON: %v", err)
|
|
}
|
|
proxies, _ := parsed["proxies"].([]any)
|
|
if len(proxies) != 1 {
|
|
t.Fatalf("expected 1 proxy, got %d", len(proxies))
|
|
}
|
|
first, _ := proxies[0].(map[string]any)
|
|
if _, ok := first["server"]; ok {
|
|
t.Error("nil server should be stripped")
|
|
}
|
|
if first["type"] != "ss" {
|
|
t.Error("type should be preserved")
|
|
}
|
|
}
|
|
|
|
func TestExtraStripNil(t *testing.T) {
|
|
m := map[string]any{"a": 1, "b": nil, "c": "x"}
|
|
result := stripNil(m)
|
|
if _, ok := result["b"]; ok {
|
|
t.Error("nil should be removed")
|
|
}
|
|
if result["a"] != 1 || result["c"] != "x" {
|
|
t.Error("non-nil should be preserved")
|
|
}
|
|
}
|
|
|
|
// --- v2ray base64 output ---
|
|
|
|
func TestExtraRenderV2rayBase64(t *testing.T) {
|
|
nodes := allNodeTypes()[:2]
|
|
out, err := RenderTarget(nodes, "v2ray", nil)
|
|
if err != nil {
|
|
t.Fatalf("v2ray render failed: %v", err)
|
|
}
|
|
// v2ray output is base64-encoded URI list
|
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(out))
|
|
if err != nil {
|
|
// Some encoders use raw URL encoding; try that
|
|
decoded, err = base64.RawURLEncoding.DecodeString(strings.TrimSpace(out))
|
|
if err != nil {
|
|
// It may also use RawStdEncoding
|
|
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(out))
|
|
if err != nil {
|
|
t.Fatalf("v2ray output is not valid base64: %v\noutput: %q", err, out)
|
|
}
|
|
}
|
|
}
|
|
if !strings.Contains(string(decoded), "://") {
|
|
t.Errorf("decoded v2ray output should contain URI scheme: %s", decoded)
|
|
}
|
|
}
|
|
|
|
// --- shadowrocket alias ---
|
|
|
|
func TestExtraShadowrocketAliasesToURI(t *testing.T) {
|
|
nodes := allNodeTypes()[:2]
|
|
outURI, err := RenderTarget(nodes, "uri", nil)
|
|
if err != nil {
|
|
t.Fatalf("uri render failed: %v", err)
|
|
}
|
|
outSR, err := RenderTarget(nodes, "shadowrocket", nil)
|
|
if err != nil {
|
|
t.Fatalf("shadowrocket render failed: %v", err)
|
|
}
|
|
if outURI != outSR {
|
|
t.Errorf("shadowrocket should alias to uri; uri=%q sr=%q", outURI, outSR)
|
|
}
|
|
}
|
|
|
|
// --- stash target (delegates to mihomo) ---
|
|
|
|
func TestExtraStashDelegatesToMihomo(t *testing.T) {
|
|
nodes := allNodeTypes()[:1]
|
|
outStash, err := RenderTarget(nodes, "stash", nil)
|
|
if err != nil {
|
|
t.Fatalf("stash render failed: %v", err)
|
|
}
|
|
if !strings.Contains(outStash, "Generated by Sub-Store") {
|
|
t.Errorf("stash should produce mihomo-style YAML: %s", outStash)
|
|
}
|
|
}
|
|
|
|
// --- additional coverage: RenderProxyUris via RenderBuildTarget ---
|
|
|
|
func TestExtraRenderBuildTargetURI(t *testing.T) {
|
|
nodes := allNodeTypes()[:2]
|
|
out, err := RenderBuildTarget(nodes, "uri", "https://x", nil)
|
|
if err != nil {
|
|
t.Fatalf("RenderBuildTarget uri failed: %v", err)
|
|
}
|
|
if !strings.Contains(out, "://") {
|
|
t.Errorf("expected URI output: %s", out)
|
|
}
|
|
}
|
|
|
|
// --- helper: entries to map ---
|
|
|
|
func entriesToMap(entries [][2]any) map[string]any {
|
|
m := make(map[string]any)
|
|
for _, e := range entries {
|
|
m[e[0].(string)] = e[1]
|
|
}
|
|
return m
|
|
}
|
|
|
|
// --- ensure URL parsing works (used by uri.go) ---
|
|
|
|
func TestExtraURIEncoding(t *testing.T) {
|
|
// Verify that special chars in passwords/names are properly encoded
|
|
node := model.ProxyNode{
|
|
"type": "trojan",
|
|
"name": "name with spaces & special",
|
|
"server": "s",
|
|
"port": 443,
|
|
"password": "p@ss/w!th$chars",
|
|
}
|
|
uri := ToProxyUri(node)
|
|
// Should be parseable as a URL
|
|
u, err := url.Parse(uri)
|
|
if err != nil {
|
|
t.Fatalf("uri not parseable: %v", err)
|
|
}
|
|
if u.Scheme != "trojan" {
|
|
t.Errorf("expected trojan scheme, got: %s", u.Scheme)
|
|
}
|
|
// name should be URL-encoded in fragment
|
|
decoded, err := url.QueryUnescape(u.Fragment)
|
|
if err != nil {
|
|
t.Fatalf("fragment not decodable: %v", err)
|
|
}
|
|
if decoded != "name with spaces & special" {
|
|
t.Errorf("expected decoded fragment, got: %q", decoded)
|
|
}
|
|
}
|
|
|
|
// --- additional regex/filter coverage ---
|
|
|
|
func TestExtraRenderTemplateProxyGroupsEmptyInput(t *testing.T) {
|
|
result := RenderTemplateProxyGroups(nil, nil)
|
|
if len(result) != 0 {
|
|
t.Errorf("expected 0 groups for nil input, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestExtraRenderTemplateProxyGroupsGroupWithOnlyFilter(t *testing.T) {
|
|
proxies := allNodeTypes()[:3]
|
|
groups := []map[string]any{
|
|
{
|
|
"name": "HK",
|
|
"type": "url-test",
|
|
"filter": "^SS-.*", // anchored to start to avoid matching VLESS-Node
|
|
},
|
|
}
|
|
result := RenderTemplateProxyGroups(proxies, groups)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 group, got %d", len(result))
|
|
}
|
|
proxiesList, _ := result[0]["proxies"].([]string)
|
|
if len(proxiesList) != 1 || proxiesList[0] != "SS-Node" {
|
|
t.Errorf("expected only SS-Node, got: %v", proxiesList)
|
|
}
|
|
}
|
|
|
|
// --- additional egern coverage ---
|
|
|
|
func TestExtraEgernVmessSecurityDefault(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vmess", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u",
|
|
}
|
|
m := ToEgernProxy(node)
|
|
if m["security"] != "auto" {
|
|
t.Errorf("expected default security=auto for vmess, got: %v", m["security"])
|
|
}
|
|
}
|
|
|
|
func TestExtraEgernVlessNoSecurity(t *testing.T) {
|
|
node := model.ProxyNode{
|
|
"type": "vless", "name": "v", "server": "s", "port": 443,
|
|
"uuid": "u",
|
|
}
|
|
m := ToEgernProxy(node)
|
|
if s, ok := m["security"]; ok && s != "" {
|
|
t.Errorf("expected empty security for vless, got: %v", s)
|
|
}
|
|
}
|