onesvm-browser-server/server/internal/proxymanager/parse_test.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

253 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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())
}
}