// parse_test.go:订阅解析/占位剔除/区域归类/分池/provider 生成/配置重写单测 // (无外网;样例 YAML 参考 lib.py 测试逻辑构造,凭据均为假数据)。 package proxymanager import ( "os" "path/filepath" "strings" "testing" ) // sampleSub 构造样例订阅:2 信息节点 + vless 多区域 + hy2 + 重名节点(凭据假数据)。 func sampleSub() string { return `mixed-port: 7890 allow-lan: false external-controller: '127.0.0.1:9090' secret: sub_secret_should_be_stripped proxies: - name: "剩余流量:100GB" type: ss server: info.example.com port: 443 - name: "套餐到期:2026-12-31" type: ss server: info2.example.com port: 443 - name: "🇺🇸 圣何塞 02 三网推荐" type: vless server: sjc02.example.com port: 443 uuid: 00000000-0000-0000-0000-000000000001 tls: true network: ws - name: "🇺🇸 洛杉矶 03 0.01倍" type: vless server: lax03.example.com port: 443 uuid: 00000000-0000-0000-0000-000000000002 tls: true - name: "🇯🇵 东京 06 高速" type: vless server: tyo06.example.com port: 443 uuid: 00000000-0000-0000-0000-000000000003 tls: true - name: "🇸🇬 狮城 01" type: vless server: sgp01.example.com port: 443 uuid: 00000000-0000-0000-0000-000000000004 tls: true - name: "🇺🇸 圣何塞 hy2 05" type: hysteria2 server: sjc05.example.com port: 8443 password: hy2pass - name: "🇺🇸 圣何塞 02 三网推荐" type: vless server: dup.example.com port: 443 uuid: dup rules: - MATCH,🚀节点选择 proxy-groups: - name: 🚀节点选择 type: select ` } func TestParseSubscription(t *testing.T) { p, err := ParseSubscription(sampleSub()) if err != nil { t.Fatalf("解析失败: %v", err) } if p.Format != "clash-yaml" { t.Errorf("format = %q, want clash-yaml", p.Format) } // 原始 8 节点:2 信息 + 5 真实(vless 4 + hy2 1)+ 1 重名剔除 if p.RawCount != 8 { t.Errorf("RawCount = %d, want 8", p.RawCount) } if len(p.InfoNodes) != 2 { t.Errorf("InfoNodes = %v, want 2 条占位", p.InfoNodes) } if len(p.Pool[poolVless]) != 4 { t.Errorf("vless 池 = %d, want 4(重名剔除后)", len(p.Pool[poolVless])) } if len(p.Pool[poolUDP]) != 1 { t.Errorf("hy2 池 = %d, want 1", len(p.Pool[poolUDP])) } // 区域归类(lib.py REGION_RULES 十二组) wantRegion := map[string]string{ "🇺🇸 圣何塞 02 三网推荐": "美国", "🇺🇸 洛杉矶 03 0.01倍": "美国", "🇯🇵 东京 06 高速": "日本", "🇸🇬 狮城 01": "新加坡", "🇺🇸 圣何塞 hy2 05": "美国", } for _, m := range p.ProxiesMeta { if wantRegion[m.Name] != m.Region { t.Errorf("region(%q) = %q, want %q", m.Name, m.Region, wantRegion[m.Name]) } } // 占位节点必须不在任何池 for _, pool := range []string{poolVless, poolUDP} { for _, rp := range p.Pool[pool] { if isInfoNode(rp.Name) { t.Errorf("占位节点 %q 泄入池 %s", rp.Name, pool) } } } } func TestInferRegionKeywords(t *testing.T) { cases := map[string]string{ "香港 IEPL 01": "香港", "台湾 Hinet": "台湾", "东京 07": "日本", "首尔 01": "韩国", "伦敦 01": "英国", "法兰克福 01": "德国", "巴黎 01": "法国", "加拿大 01": "加拿大", "悉尼 澳大利亚": "澳大利亚", "澳门 01": "澳门", "中国 回国专线": "中国", "unknown-node": unknownRegion, } for name, want := range cases { if got := inferRegion(name); got != want { t.Errorf("inferRegion(%q) = %q, want %q", name, got, want) } } } func TestIsInfoNode(t *testing.T) { // lib.py INFO_NAME_RE = ^(?剩余流量|套餐到期|到期|流量)——前缀匹配语义, // 「流量转发」等以「流量」开头的名称同样命中(与 lib.py 语义一致)。 yes := []string{"剩余流量:10GB", "套餐到期:2026", "到期时间", "流量无限", "流量转发 01"} no := []string{"🇺🇸 圣何塞 02", "剩余/套餐提示位 01"} for _, n := range yes { if !isInfoNode(n) { t.Errorf("isInfoNode(%q) = false, want true", n) } } for _, n := range no { if isInfoNode(n) { t.Errorf("isInfoNode(%q) = true, want false", n) } } } func TestDetectFormat(t *testing.T) { if got := detectFormat(sampleSub()); got != "clash-yaml" { t.Errorf("clash yaml 探测 = %q", got) } if got := detectFormat("ss://abc\nvmess://def"); got != "uri-list" { t.Errorf("uri list 探测 = %q", got) } if got := detectFormat(strings.Repeat("QUJDREVGRw==", 20)); got != "base64-maybe" { t.Errorf("base64 探测 = %q", got) } } func TestParseRejectsNonClash(t *testing.T) { if _, err := ParseSubscription("ss://abc"); err == nil { t.Fatal("非 clash-yaml 应返回错误") } } func TestBuildProviderConfig(t *testing.T) { cfg, err := BuildProviderConfig(sampleSub()) if err != nil { t.Fatalf("生成失败: %v", err) } // 凭据段保留(uuid 原样在配置中——凭据只进运行时配置文件) if !strings.Contains(cfg, "00000000-0000-0000-0000-000000000001") { t.Error("provider 配置丢失 vless uuid 凭据段") } // 端口重写(up.sh 惯例:mixed 17890 / controller 19090) if !strings.Contains(cfg, "mixed-port: 17890") { t.Error("provider 未重写 mixed-port=17890") } if !strings.Contains(cfg, "0.0.0.0:19090") { t.Error("provider 未重写 external-controller") } // 订阅自带 secret/端口/rules/proxy-groups 剥离 if strings.Contains(cfg, "sub_secret_should_be_stripped") { t.Error("provider 保留了订阅自带 secret") } if !strings.Contains(cfg, "- MATCH,EXIT-POOL") { t.Error("provider 缺 MATCH 规则") } if strings.Contains(cfg, "mixed-port: 7890") || strings.Contains(cfg, "port: 7890") { t.Error("provider 保留了订阅原端口 7890") } if strings.Contains(cfg, "🚀节点选择") { t.Error("provider 保留了订阅自带策略组") } // 订阅自带 dns/geosite 必须剥离(W6:保留会打爆 64m) fat := "dns:\n enable: true\n fallback-filter:\n geosite:\n - gfw\ngeox-url:\n geosite: http://example.invalid/geosite.dat\n" + sampleSub() slim, err := BuildProviderConfig(fat) if err != nil { t.Fatalf("含 dns 的订阅应仍能生成: %v", err) } if strings.Contains(slim, "geosite") || strings.Contains(slim, "geox-url") { t.Error("provider 不应保留 geosite/geox-url") } // 内置组存在 for _, g := range []string{"EXIT-POOL", "VLESS-MAIN", "UDP-OPTIONAL"} { if !strings.Contains(cfg, "name: "+g) { t.Errorf("provider 缺内置组 %s", g) } } } func TestWriteProviderPerms(t *testing.T) { dir := t.TempDir() cfg := "mixed-port: 17890\n" if err := WriteProvider(dir, cfg); err != nil { t.Fatalf("落盘失败: %v", err) } fi, err := os.Stat(ProviderPath(dir)) if err != nil { t.Fatalf("stat 失败: %v", err) } if fi.Mode().Perm() != 0o600 { t.Errorf("provider.yaml 权限 = %o, want 600", fi.Mode().Perm()) } if _, err := os.Stat(filepath.Join(dir, "config.yaml")); err != nil { t.Fatalf("应同时写 config.yaml(mihomo 启动名): %v", err) } if p := MihomoReloadPath(); p != "/root/.config/mihomo/config.yaml" { t.Errorf("缺省热载路径 = %s", p) } di, _ := os.Stat(dir) _ = di // t.TempDir 本身 0700,MkdirAll 后 Chmod(0700) 兜底 } func TestWriteProviderDirPerm(t *testing.T) { dir := filepath.Join(t.TempDir(), "mihomo") if err := WriteProvider(dir, "x: 1\n"); err != nil { t.Fatalf("落盘失败: %v", err) } fi, err := os.Stat(dir) if err != nil { t.Fatal(err) } if fi.Mode().Perm() != 0o700 { t.Errorf("配置目录权限 = %o, want 700", fi.Mode().Perm()) } }