Files
subconverter-go/tests/e2e/conversion_compat_test.go
Rogee 7fcabe0225
Some checks failed
CI/CD Pipeline / Test (push) Failing after 22m19s
CI/CD Pipeline / Security Scan (push) Failing after 5m57s
CI/CD Pipeline / Build (amd64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (amd64, linux) (push) Has been skipped
CI/CD Pipeline / Build (amd64, windows) (push) Has been skipped
CI/CD Pipeline / Build (arm64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (arm64, linux) (push) Has been skipped
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Create Release (push) Has been skipped
first commit
2025-09-28 10:05:07 +08:00

1775 lines
52 KiB
Go

package e2e
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"strconv"
yaml "gopkg.in/yaml.v3"
)
func newHybridHTTPClient(hosts ...string) *http.Client {
allowed := make(map[string]struct{}, len(hosts))
for _, h := range hosts {
allowed[h] = struct{}{}
}
return &http.Client{
Timeout: 5 * time.Second,
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if _, ok := allowed[req.URL.Host]; ok {
return http.DefaultTransport.RoundTrip(req)
}
body := subscriptionFixture(req.URL.String())
status := http.StatusOK
if body == "" {
status = http.StatusNotFound
}
resp := &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
resp.Header.Set("Content-Type", "text/plain; charset=utf-8")
return resp, nil
}),
}
}
func TestHeadSubEndpoint(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26600
logging:
level: "error"
format: "text"
conversion:
cache_timeout: 60
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
resp := doRequest(t, server, http.MethodHead, "/sub?target=clash&url=https://mock-subscribe.example.com")
if resp.Body != nil {
defer resp.Body.Close()
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for HEAD /sub, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text") {
t.Fatalf("expected text content type, got %q", ct)
}
if resp.ContentLength > 0 {
t.Fatalf("expected empty body for HEAD request, content length %d", resp.ContentLength)
}
if resp.Body != nil {
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed reading head body: %v", err)
}
if len(data) != 0 {
t.Fatalf("expected zero-length body, got %d", len(data))
}
}
}
func TestSub2ClashREndpoint(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26610
logging:
level: "error"
format: "text"
conversion:
cache_timeout: 60
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
t.Run("MissingSublink", func(t *testing.T) {
resp := doRequest(t, server, http.MethodGet, "/sub2clashr")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for missing sublink, got %d", resp.StatusCode)
}
})
t.Run("PlaceholderSublink", func(t *testing.T) {
resp := doRequest(t, server, http.MethodGet, "/sub2clashr?sublink=sublink")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for placeholder sublink, got %d", resp.StatusCode)
}
})
t.Run("ValidSublink", func(t *testing.T) {
resp := doRequest(t, server, http.MethodGet, "/sub2clashr?sublink=https://mock-subscribe.example.com")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for valid sublink, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text") {
t.Fatalf("expected text content type, got %q", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read sub2clashr body: %v", err)
}
if len(body) == 0 {
t.Fatalf("expected non-empty body from sub2clashr")
}
})
}
func TestSurge2ClashEndpoint(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26620
logging:
level: "error"
format: "text"
conversion:
cache_timeout: 60
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
t.Run("MissingLink", func(t *testing.T) {
resp := doRequest(t, server, http.MethodGet, "/surge2clash")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for missing link, got %d", resp.StatusCode)
}
})
t.Run("ValidLink", func(t *testing.T) {
resp := doRequest(t, server, http.MethodGet, "/surge2clash?link=https://mock-subscribe.example.com")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for surge2clash conversion, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if len(body) == 0 {
t.Fatalf("expected surge2clash body to be non-empty")
}
})
}
func TestGetLocalAndRemoteEndpoints(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26630
logging:
level: "error"
format: "text"
conversion:
cache_timeout: 60
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
tempDir := t.TempDir()
localFile := filepath.Join(tempDir, "sample.txt")
if err := os.WriteFile(localFile, []byte("local-content"), 0o644); err != nil {
t.Fatalf("failed to write local file: %v", err)
}
t.Run("GetLocal", func(t *testing.T) {
pathParam := url.QueryEscape(localFile)
resp := doRequest(t, server, http.MethodGet, fmt.Sprintf("/getlocal?path=%s", pathParam))
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for getlocal, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read getlocal body: %v", err)
}
if string(body) != "local-content" {
t.Fatalf("unexpected getlocal content: %q", string(body))
}
})
t.Run("GetRemote", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("remote-content"))
})
serverTS := httptest.NewServer(handler)
defer serverTS.Close()
resp := doRequest(t, server, http.MethodGet, fmt.Sprintf("/get?url=%s", url.QueryEscape(serverTS.URL)))
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for get remote, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read get body: %v", err)
}
if string(body) != "remote-content" {
t.Fatalf("unexpected remote content: %q", string(body))
}
})
}
func TestRenderAndGetProfileRules(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26640
logging:
level: "error"
format: "text"
conversion:
cache_timeout: 60
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
t.Run("RenderTemplate", func(t *testing.T) {
params := url.Values{}
params.Set("path", "GeneralClashConfig.tpl")
params.Set("target", "clash")
resp := doRequest(t, server, http.MethodGet, "/render?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for render, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read render body: %v", err)
}
if len(body) == 0 {
t.Fatalf("expected non-empty render output")
}
})
t.Run("GetProfile", func(t *testing.T) {
profileDir := t.TempDir()
profileFile := filepath.Join(profileDir, "profile.ini")
content := "[Profile]\nprofile_token=test-token\nurl=https://example.com/sub\n"
if err := os.WriteFile(profileFile, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write profile file: %v", err)
}
params := url.Values{}
params.Set("name", profileFile)
params.Set("token", "test-token")
resp := doRequest(t, server, http.MethodGet, "/getprofile?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for getprofile, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read profile body: %v", err)
}
if string(body) != content {
t.Fatalf("unexpected profile content: %q", string(body))
}
})
t.Run("GetRuleset", func(t *testing.T) {
rulesetPath := filepath.Join("rules", "LocalAreaNetwork.list")
encoded := base64.StdEncoding.EncodeToString([]byte("ruleset," + rulesetPath))
params := url.Values{}
params.Set("type", "1")
params.Set("url", encoded)
resp := doRequest(t, server, http.MethodGet, "/getruleset?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for getruleset, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read ruleset body: %v", err)
}
if len(body) == 0 {
t.Fatalf("expected non-empty ruleset content")
}
})
}
func TestAPIConvertRequestOptions(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26650
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("emoji", "true")
params.Set("udp", "true")
params.Set("ipv6", "true")
params.Set("insert", "true")
params.Set("strict", "true")
params.Set("list", "true")
params.Set("append_type", "true")
params.Set("tfo", "true")
params.Set("script", "true")
params.Set("scv", "true")
params.Set("fdn", "true")
params.Set("expand", "true")
params.Set("append_info", "true")
params.Set("prepend", "true")
params.Set("classic", "true")
params.Set("tls13", "true")
params.Set("add_emoji", "true")
params.Set("remove_emoji", "true")
params.Set("upload", "true")
params.Set("group", "test-group")
params.Set("config", "https://example.com/config.yaml")
params.Set("include", "hk,sg")
params.Set("exclude", "cn")
params.Set("upload_path", "https://example.com/upload")
params.Set("rename", "foo`bar")
params.Set("filter_script", "filters.lua")
params.Set("dev_id", "device-123")
params.Set("interval", "7200")
groupPayload := base64.StdEncoding.EncodeToString([]byte("name=TestGroup&test=true"))
params.Set("groups", groupPayload)
rulesetPayload := base64.StdEncoding.EncodeToString([]byte("ruleset,rules/LocalAreaNetwork.list"))
params.Set("ruleset", rulesetPayload)
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for api convert, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
optionsVal, ok := body["request_options"].(map[string]interface{})
if !ok {
t.Fatalf("missing request_options in response: %v", body)
}
assertBool := func(key string, expected bool) {
val, ok := optionsVal[key]
if !ok {
t.Fatalf("missing option %s", key)
}
boolVal, ok := val.(bool)
if !ok {
t.Fatalf("option %s not boolean: %T", key, val)
}
if boolVal != expected {
t.Fatalf("option %s expected %v got %v", key, expected, boolVal)
}
}
assertString := func(key, expected string) {
val, ok := optionsVal[key]
if !ok {
t.Fatalf("missing option %s", key)
}
strVal, ok := val.(string)
if !ok {
t.Fatalf("option %s not string: %T", key, val)
}
if strVal != expected {
t.Fatalf("option %s expected %s got %s", key, expected, strVal)
}
}
assertBool("emoji", true)
assertBool("udp", true)
assertBool("ipv6", true)
assertBool("insert", true)
assertBool("strict", true)
assertBool("list", true)
assertBool("append_type", true)
assertBool("tfo", true)
assertBool("script", true)
assertBool("scv", true)
assertBool("fdn", true)
assertBool("expand", true)
assertBool("append_info", true)
assertBool("prepend", true)
assertBool("classic", true)
assertBool("tls13", true)
assertBool("add_emoji", true)
assertBool("remove_emoji", true)
assertBool("upload", true)
assertString("group", "test-group")
assertString("config", "https://example.com/config.yaml")
assertString("include", "hk,sg")
assertString("exclude", "cn")
assertString("upload_path", "https://example.com/upload")
assertString("rename", "foo`bar")
assertString("filter_script", "filters.lua")
assertString("dev_id", "device-123")
assertString("interval", "7200")
assertString("groups", "name=TestGroup&test=true")
assertString("ruleset", "ruleset,rules/LocalAreaNetwork.list")
}
func TestClashProxyProviders(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26665
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
groupValues := url.Values{}
groupValues.Set("name", "ProviderSelect")
groupValues.Set("type", "select")
groupValues.Set("use", "HK,JP")
groupEncoded := base64.StdEncoding.EncodeToString([]byte(groupValues.Encode()))
providerHK := url.Values{}
providerHK.Set("name", "HK")
providerHK.Set("type", "http")
providerHK.Set("url", "https://example.com/providers/hk.yaml")
providerHK.Set("path", "proxy-providers/HK.yaml")
providerHK.Set("interval", "86400")
providerHK.Set("health_enable", "true")
providerHK.Set("health_url", "http://www.gstatic.com/generate_204")
providerHK.Set("health_interval", "300")
providerJP := url.Values{}
providerJP.Set("name", "JP")
providerJP.Set("type", "http")
providerJP.Set("url", "https://example.com/providers/jp.yaml")
providerJP.Set("path", "proxy-providers/JP.yaml")
providerJP.Set("interval", "43200")
providerJP.Set("health_enable", "false")
providersEncoded := encodeProvidersForTest(providerHK, providerJP)
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("groups", groupEncoded)
params.Set("providers", providersEncoded)
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read provider response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 from api convert with providers, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var body map[string]interface{}
if err := json.Unmarshal(bodyBytes, &body); err != nil {
t.Fatalf("failed to decode api response: %v", err)
}
content, ok := body["content"].(string)
if !ok {
t.Fatalf("missing content in api response: %#v", body)
}
generated := parseClashYAML(t, content)
providersAny, ok := generated["proxy-providers"].(map[string]interface{})
if !ok {
t.Fatalf("proxy-providers section missing: %#v", generated)
}
hkAny, ok := providersAny["HK"].(map[string]interface{})
if !ok {
t.Fatalf("HK provider missing: %#v", providersAny)
}
if hkAny["type"] != "http" {
t.Fatalf("unexpected HK provider type: %#v", hkAny["type"])
}
if hkAny["url"] != "https://example.com/providers/hk.yaml" {
t.Fatalf("unexpected HK provider url: %#v", hkAny["url"])
}
if hkAny["path"] != "proxy-providers/HK.yaml" {
t.Fatalf("unexpected HK provider path: %#v", hkAny["path"])
}
if interval, ok := asNumber(hkAny["interval"]); !ok || interval != 86400 {
t.Fatalf("unexpected HK provider interval: %#v", hkAny["interval"])
}
healthAny, ok := hkAny["health-check"].(map[string]interface{})
if !ok {
t.Fatalf("health-check block missing for HK provider: %#v", hkAny)
}
if healthAny["enable"] != true {
t.Fatalf("health-check enable not true: %#v", healthAny["enable"])
}
if healthAny["url"] != "http://www.gstatic.com/generate_204" {
t.Fatalf("unexpected health-check url: %#v", healthAny["url"])
}
if val, ok := asNumber(healthAny["interval"]); !ok || val != 300 {
t.Fatalf("unexpected health-check interval: %#v", healthAny["interval"])
}
jpAny, ok := providersAny["JP"].(map[string]interface{})
if !ok {
t.Fatalf("JP provider missing: %#v", providersAny)
}
if val, ok := asNumber(jpAny["interval"]); !ok || val != 43200 {
t.Fatalf("unexpected JP interval: %#v", jpAny["interval"])
}
groupsAny, ok := generated["proxy-groups"].([]interface{})
if !ok {
t.Fatalf("proxy-groups missing: %#v", generated)
}
found := false
for _, raw := range groupsAny {
group, ok := raw.(map[string]interface{})
if !ok {
continue
}
if group["name"] == "ProviderSelect" {
found = true
useList, ok := group["use"].([]interface{})
if !ok {
t.Fatalf("expected ProviderSelect group to contain use list: %#v", group)
}
if len(useList) != 2 || useList[0] != "HK" || useList[1] != "JP" {
t.Fatalf("unexpected provider use list: %#v", useList)
}
break
}
}
if !found {
t.Fatalf("custom group ProviderSelect not found: %#v", groupsAny)
}
}
func TestSurgeProxyProviders(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26666
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
groupEncoded := encodeGroupForTest(url.Values{
"name": {"ProviderSelect"},
"type": {"select"},
"use": {"HK,JP"},
})
providerHK := url.Values{}
providerHK.Set("name", "HK")
providerHK.Set("type", "http")
providerHK.Set("url", "https://example.com/providers/hk.yaml")
providerHK.Set("path", "proxy-providers/HK.yaml")
providerHK.Set("interval", "86400")
providerHK.Set("health_enable", "true")
providerHK.Set("health_url", "http://www.gstatic.com/generate_204")
providerHK.Set("health_interval", "300")
providerJP := url.Values{}
providerJP.Set("name", "JP")
providerJP.Set("type", "http")
providerJP.Set("url", "https://example.com/providers/jp.yaml")
providerJP.Set("path", "proxy-providers/JP.yaml")
providerJP.Set("interval", "43200")
providersEncoded := encodeProvidersForTest(providerHK, providerJP)
params := url.Values{}
params.Set("target", "surge")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("groups", groupEncoded)
params.Set("providers", providersEncoded)
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read surge provider response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 from api convert with surge providers, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var body map[string]interface{}
if err := json.Unmarshal(bodyBytes, &body); err != nil {
t.Fatalf("failed to decode api response: %v", err)
}
content, ok := body["content"].(string)
if !ok {
t.Fatalf("missing content in api response: %#v", body)
}
if !strings.Contains(content, "[Proxy Provider]") {
t.Fatalf("expected Proxy Provider section in surge output: %s", content)
}
if !strings.Contains(content, "HK = http, https://example.com/providers/hk.yaml") {
t.Fatalf("expected HK provider line, got: %s", content)
}
if !strings.Contains(content, "path=proxy-providers/HK.yaml") {
t.Fatalf("expected HK provider path entry, got: %s", content)
}
if !strings.Contains(content, "interval=86400") {
t.Fatalf("expected HK provider interval entry, got: %s", content)
}
if !strings.Contains(content, "health-check-url=http://www.gstatic.com/generate_204") {
t.Fatalf("expected health-check url entry, got: %s", content)
}
if !strings.Contains(content, "ProviderSelect = select") || !strings.Contains(content, "use-provider=HK") || !strings.Contains(content, "use-provider=JP") {
t.Fatalf("expected ProviderSelect custom group referencing providers, got: %s", content)
}
}
func TestQuantumultXProxyProviders(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26667
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
groupEncoded := encodeGroupForTest(url.Values{
"name": {"ProviderSelect"},
"type": {"select"},
"use": {"HK,JP"},
})
providerHK := url.Values{}
providerHK.Set("name", "HK")
providerHK.Set("type", "http")
providerHK.Set("url", "https://example.com/providers/hk.yaml")
providerHK.Set("path", "proxy-providers/HK.yaml")
providerHK.Set("interval", "86400")
providerJP := url.Values{}
providerJP.Set("name", "JP")
providerJP.Set("type", "http")
providerJP.Set("url", "https://example.com/providers/jp.yaml")
providerJP.Set("path", "proxy-providers/JP.yaml")
providerJP.Set("interval", "43200")
providersEncoded := encodeProvidersForTest(providerHK, providerJP)
params := url.Values{}
params.Set("target", "quanx")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("groups", groupEncoded)
params.Set("providers", providersEncoded)
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read quantumultx provider response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 from api convert with quantumultx providers, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var body map[string]interface{}
if err := json.Unmarshal(bodyBytes, &body); err != nil {
t.Fatalf("failed to decode api response: %v", err)
}
content, ok := body["content"].(string)
if !ok {
t.Fatalf("missing content in api response: %#v", body)
}
if !strings.Contains(content, "[Server Remote]") {
t.Fatalf("expected Server Remote section in quantumultx output: %s", content)
}
if !strings.Contains(content, "HK = https://example.com/providers/hk.yaml") {
t.Fatalf("expected HK remote server entry, got: %s", content)
}
if !strings.Contains(content, "tag=HK") {
t.Fatalf("expected tag=HK entry, got: %s", content)
}
if !strings.Contains(content, "update-interval=86400") {
t.Fatalf("expected update-interval entry, got: %s", content)
}
if !strings.Contains(content, "server-tag-regex=^(HK|JP)$") {
t.Fatalf("expected policy referencing provider tags, got: %s", content)
}
}
func TestProviderValidationErrors(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26668
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
// Missing path value should trigger validation failure
invalidProvider := url.Values{}
invalidProvider.Set("name", "Broken")
invalidProvider.Set("type", "http")
invalidProvider.Set("url", "https://example.com/broken.yaml")
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("providers", base64.StdEncoding.EncodeToString([]byte(invalidProvider.Encode())))
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid provider, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode error response: %v", err)
}
if success, _ := body["success"].(bool); success {
t.Fatalf("expected success=false for invalid provider, got: %#v", body)
}
}
func encodeGroupForTest(values url.Values) string {
return base64.StdEncoding.EncodeToString([]byte(values.Encode()))
}
func encodeProvidersForTest(defs ...url.Values) string {
encoded := make([]string, 0, len(defs))
for _, v := range defs {
encoded = append(encoded, v.Encode())
}
return base64.StdEncoding.EncodeToString([]byte(strings.Join(encoded, "@")))
}
func parseClashYAML(t *testing.T, content string) map[string]interface{} {
t.Helper()
var parsed map[string]interface{}
if err := yaml.Unmarshal([]byte(content), &parsed); err != nil {
t.Fatalf("failed to decode generated clash content: %v", err)
}
return parsed
}
func asNumber(value interface{}) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case int64:
return int(v), true
case float64:
return int(v), true
case string:
if v == "" {
return 0, false
}
if i, err := strconv.Atoi(v); err == nil {
return i, true
}
}
return 0, false
}
func hasEmojiPrefix(name string) bool {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return false
}
r, _ := utf8.DecodeRuneInString(trimmed)
switch r {
case '🛰', '🛸', '🐴':
return true
}
// Regional indicator symbols (flags)
if r >= 0x1F1E6 && r <= 0x1F1FF {
return true
}
return false
}
func TestSubscriptionFilenameDisposition(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26655
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
path := "/sub?target=clash&url=https://mock-subscribe.example.com&filename=custom.yaml"
resp := doRequest(t, server, http.MethodGet, path)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /sub with filename, got %d", resp.StatusCode)
}
cd := resp.Header.Get("Content-Disposition")
if cd == "" {
t.Fatalf("expected Content-Disposition header")
}
if !strings.Contains(cd, "attachment") || !strings.Contains(cd, "custom.yaml") {
t.Fatalf("unexpected content-disposition header: %q", cd)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read subscription body: %v", err)
}
if len(body) == 0 {
t.Fatalf("expected non-empty body for subscription response")
}
}
func TestAPIAutoTargetResolution(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26690
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
path := "/api/convert?target=auto&url=https://mock-subscribe.example.com"
resp := doRequestWithBody(t, server, http.MethodGet, path, nil, map[string]string{"User-Agent": "Surge/1400"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for auto target with UA, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if format, _ := body["target_format"].(string); format != "surge" {
t.Fatalf("expected resolved target_format surge, got %q", format)
}
resp = doRequest(t, server, http.MethodGet, path)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for auto target without UA, got %d", resp.StatusCode)
}
body = map[string]interface{}{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode fallback response: %v", err)
}
if format, _ := body["target_format"].(string); format != "clash" {
t.Fatalf("expected fallback target_format clash, got %q", format)
}
}
func TestRemoteFetchUsesRequestUserAgent(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26705
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
engine := app.GetConversionEngine()
if engine == nil {
t.Fatalf("expected conversion engine instance")
}
var clashFetches int32
var clashFallbackFetches int32
client := &http.Client{
Timeout: 5 * time.Second,
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
resp := &http.Response{
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
resp.Header.Set("Content-Type", "text/plain; charset=utf-8")
switch req.URL.Path {
case "/clash":
if ua := req.Header.Get("User-Agent"); ua != "ClashForWindows/0.20.0" {
resp.StatusCode = http.StatusForbidden
resp.Body = io.NopCloser(strings.NewReader("unexpected ua"))
return resp, nil
}
atomic.AddInt32(&clashFetches, 1)
resp.StatusCode = http.StatusOK
resp.Body = io.NopCloser(strings.NewReader("ss://aes-256-cfb:password@198.51.100.2:8388"))
return resp, nil
case "/clash-default":
if ua := req.Header.Get("User-Agent"); ua != "ClashForWindows/0.20.0" {
resp.StatusCode = http.StatusForbidden
resp.Body = io.NopCloser(strings.NewReader("unexpected ua"))
return resp, nil
}
atomic.AddInt32(&clashFallbackFetches, 1)
resp.StatusCode = http.StatusOK
resp.Body = io.NopCloser(strings.NewReader("ss://aes-256-cfb:password@198.51.100.3:8388"))
return resp, nil
default:
resp.StatusCode = http.StatusNotFound
return resp, nil
}
}),
}
engine.SetHTTPClient(client)
server := app.GetHTTPServer()
t.Run("forwards client user agent", func(t *testing.T) {
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://ua-verify.test/clash")
resp := doRequestWithBody(t, server, http.MethodGet, "/api/convert?"+params.Encode(), nil, map[string]string{"User-Agent": "ClashForWindows/0.20.0"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 when forwarding UA, got %d", resp.StatusCode)
}
var payload map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if ok, _ := payload["success"].(bool); !ok {
t.Fatalf("expected success flag in response")
}
if got := atomic.LoadInt32(&clashFetches); got != 1 {
t.Fatalf("expected exactly one clash fetch, got %d", got)
}
})
t.Run("falls back to target default user agent", func(t *testing.T) {
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://ua-verify.test/clash-default")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for default UA fallback, got %d", resp.StatusCode)
}
var payload map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if ok, _ := payload["success"].(bool); !ok {
t.Fatalf("expected success flag in fallback response")
}
if got := atomic.LoadInt32(&clashFallbackFetches); got != 1 {
t.Fatalf("expected exactly one fallback fetch, got %d", got)
}
})
}
func TestAPIMultiSourceSubscriptionMerge(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26710
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://multi-source.example.com/first|https://multi-source.example.com/second")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200 for multi-source merge, got %d, body: %s", resp.StatusCode, string(body))
}
var payload map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if ok, _ := payload["success"].(bool); !ok {
content, _ := json.Marshal(payload)
t.Fatalf("expected success in multi-source response, got %s", string(content))
}
nodeCount, ok := payload["node_count"].(float64)
if !ok {
t.Fatalf("expected numeric node_count, got %T", payload["node_count"])
}
if int(nodeCount) != 2 {
content, _ := json.Marshal(payload)
t.Fatalf("expected node_count 2, got %d payload=%s", int(nodeCount), string(content))
}
content, ok := payload["content"].(string)
if !ok {
t.Fatalf("expected content string, got %T", payload["content"])
}
if !strings.Contains(content, "multi-ss") {
t.Fatalf("expected merged content to include first source proxy name, got %s", content)
}
if !strings.Contains(content, "multi-vmess") {
t.Fatalf("expected merged content to include second source proxy name, got %s", content)
}
if opts, ok := payload["request_options"].(map[string]interface{}); ok {
if val, ok := opts["url"].(string); ok {
expected := "https://multi-source.example.com/first|https://multi-source.example.com/second"
if val != expected {
t.Fatalf("expected request_options url %q, got %q", expected, val)
}
}
}
}
func TestSubscriptionListMode(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26695
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
path := "/sub?target=clash&url=https://mock-subscribe.example.com&list=true"
resp := doRequest(t, server, http.MethodGet, path)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for list mode, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" {
t.Fatalf("expected text/plain content type, got %q", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read list body: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
if len(lines) < 2 {
t.Fatalf("expected multiple entries in list output, got %v", lines)
}
for _, line := range lines {
if strings.TrimSpace(line) == "" {
t.Fatalf("found empty entry in list output: %v", lines)
}
}
}
func TestAPIRenameRules(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26700
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("rename", "ss-@renamed-")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for rename rule, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, ok := body["content"].(string)
if !ok {
t.Fatalf("content missing or not string: %v", body)
}
if !strings.Contains(content, "renamed-") {
t.Fatalf("expected renamed proxy name in content, got %s", content)
}
}
func TestAPIRenameScript(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26701
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("list", "true")
params.Set("rename", "!!script:function rename(node) { return 'JS-' + node.Remark; }")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for rename script, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, _ := payload["content"].(string)
if !strings.Contains(content, "JS-") {
t.Fatalf("expected JS- prefix in content, got %s", content)
}
}
func TestAPIRenameImportScript(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26703
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
scriptDir := t.TempDir()
scriptPath := filepath.Join(scriptDir, "rename.js")
scriptContent := "function rename(node) { return 'IM-' + node.Remark; }"
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0o644); err != nil {
t.Fatalf("failed to write script file: %v", err)
}
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("list", "true")
params.Set("rename", "!!import:"+scriptPath)
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for imported rename script, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, _ := payload["content"].(string)
if !strings.Contains(content, "IM-") {
t.Fatalf("expected IM- prefix in content, got %s", content)
}
}
func TestAPIRenameGeoIPScript(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26706
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("list", "true")
params.Set("rename", "!!script:function rename(node) { const info = JSON.parse(node.ProxyInfo); const geo = JSON.parse(geoip(info.Hostname)); if (geo.country_code === 'PRIVATE') { return 'LAN-' + node.Remark; } }")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for geoip rename script, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, _ := payload["content"].(string)
if !strings.Contains(content, "LAN-") {
t.Fatalf("expected LAN- prefix in content, got %s", content)
}
}
func TestAPIRenameImportScriptRemote(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26707
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
scriptServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "function rename(node) { return 'REM-' + node.Remark; }")
}))
defer scriptServer.Close()
scriptURL, err := url.Parse(scriptServer.URL)
if err != nil {
t.Fatalf("failed to parse script server url: %v", err)
}
app.GetConversionEngine().SetHTTPClient(newHybridHTTPClient(scriptURL.Host))
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("list", "true")
params.Set("rename", "!!import:"+scriptServer.URL+"/rename.js")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for remote rename import, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, _ := payload["content"].(string)
if !strings.Contains(content, "REM-") {
t.Fatalf("expected REM- prefix in content, got %s", content)
}
}
func TestAPIConvertRemoteSubscriptionCaching(t *testing.T) {
var hitCount int32
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hitCount, 1)
fmt.Fprint(w, "ss://aes-256-cfb:password@198.18.0.1:8388\nvmess://eyJhZGQiOiIxOTguMTguMC4yIiwgInBzIjoiVm1lc3MgRemoteIiwgInBvcnQiOjQ0MywgImlkIjoiZDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDAifQ==\ntrojan://password@198.18.0.3:443")
}))
defer backend.Close()
config := `server:
host: "127.0.0.1"
port: 26708
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
backendURL, err := url.Parse(backend.URL)
if err != nil {
t.Fatalf("failed to parse backend url: %v", err)
}
app.GetConversionEngine().SetHTTPClient(newHybridHTTPClient(backendURL.Host))
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", backend.URL)
params.Set("list", "true")
for i := 0; i < 2; i++ {
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
bodyBytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("iteration %d: failed to read response: %v", i, err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("iteration %d: expected 200, got %d; body=%s", i, resp.StatusCode, string(bodyBytes))
}
if !strings.Contains(string(bodyBytes), "content") {
t.Fatalf("iteration %d: unexpected response payload %s", i, string(bodyBytes))
}
}
if atomic.LoadInt32(&hitCount) != 1 {
t.Fatalf("expected backend to be hit once, got %d", hitCount)
}
}
func TestAPIFilterScript(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26709
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("list", "true")
params.Set("filter_script", "function filter(node) { return node.Remark.indexOf('ss-') !== -1; }")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
bodyBytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for filter script, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, _ := payload["content"].(string)
if strings.Contains(content, "ss-192.168.1.1") {
t.Fatalf("expected filter script to remove ss node, got %s", content)
}
if !(strings.Contains(content, "Test VMess") || strings.Contains(content, "trojan-")) {
t.Fatalf("expected remaining nodes to include a non-ss entry, got %s", content)
}
}
func TestAPIUploadToFile(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26710
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "surge.conf")
params := url.Values{}
params.Set("target", "surge")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("upload", "true")
params.Set("interval", "7200")
params.Set("strict", "true")
params.Set("upload_path", "file://"+outputPath)
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
bodyBytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for upload request, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if len(data) == 0 {
t.Fatalf("expected uploaded file to have content")
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
headers, _ := payload["headers"].(map[string]interface{})
if val, ok := headers["profile-update-interval"].(string); !ok || val != "2" {
t.Fatalf("expected profile-update-interval=2, got %v", headers["profile-update-interval"])
}
if strings.Contains(string(data), "#!MANAGED-CONFIG") {
t.Fatalf("managed header should not be included in uploaded artifact")
}
}
func TestAPIEmojiScript(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26704
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("list", "true")
params.Set("add_emoji", "true")
params.Set("emoji_rule", "!!script:function getEmoji(node) { return '🧪'; }")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for emoji script, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, _ := payload["content"].(string)
if !strings.Contains(content, "🧪") {
t.Fatalf("expected emoji in content, got %s", content)
}
}
func TestAPIRenameScriptError(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26705
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("rename", "!!script:function notrename(node) { return 'BAD'; }")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid rename script, got %d; body=%s", resp.StatusCode, string(bodyBytes))
}
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if success, _ := payload["success"].(bool); success {
t.Fatalf("expected success=false for invalid script, got %v", payload)
}
}
func TestAPICustomGroupsAndRulesets(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26702
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
groupDef := base64.StdEncoding.EncodeToString([]byte("name=Manual&type=select&proxies=ss-192.168.1.1:8388,vmess-192.168.1.2:443"))
ruleDef := base64.StdEncoding.EncodeToString([]byte("DOMAIN-SUFFIX,example.com,Manual"))
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("groups", groupDef)
params.Set("ruleset", ruleDef)
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for custom groups/rulesets, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, ok := body["content"].(string)
if !ok {
t.Fatalf("missing content in response: %v", body)
}
parsed := parseClashYAML(t, content)
groupsVal, ok := parsed["proxy-groups"].([]interface{})
if !ok {
t.Fatalf("missing proxy-groups in config: %v", parsed)
}
foundManual := false
for _, entry := range groupsVal {
group, ok := entry.(map[string]interface{})
if !ok {
continue
}
if group["name"] == "Manual" {
proxies, _ := group["proxies"].([]interface{})
if len(proxies) == 0 {
t.Fatalf("manual group missing proxies: %v", group)
}
foundNames := make([]string, 0, len(proxies))
for _, p := range proxies {
if s, ok := p.(string); ok {
foundNames = append(foundNames, s)
}
}
if !contains(foundNames, "ss-192.168.1.1:8388") || !contains(foundNames, "vmess-192.168.1.2:443") {
t.Fatalf("manual group missing expected proxies: %v", foundNames)
}
foundManual = true
break
}
}
if !foundManual {
t.Fatalf("custom group Manual not found: %v", groupsVal)
}
if !strings.Contains(content, "DOMAIN-SUFFIX,example.com,Manual") {
t.Fatalf("custom ruleset not applied: %s", content)
}
}
func TestAPIEEmojiToggle(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26705
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("add_emoji", "true")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for add emoji, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, _ := body["content"].(string)
parsed := parseClashYAML(t, content)
proxies, ok := parsed["proxies"].([]interface{})
if !ok || len(proxies) == 0 {
t.Fatalf("expected proxies in generated content, got %#v", parsed)
}
foundEmoji := false
for _, entry := range proxies {
if proxyMap, ok := entry.(map[string]interface{}); ok {
if name, ok := proxyMap["name"].(string); ok && hasEmojiPrefix(name) {
foundEmoji = true
break
}
}
}
if !foundEmoji {
t.Fatalf("expected emoji-prefixed proxies, got %s", content)
}
params = url.Values{}
params.Set("target", "clash")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("rename", ".*@🇭🇰 $0")
params.Set("remove_emoji", "true")
resp = doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for remove emoji, got %d", resp.StatusCode)
}
body = map[string]interface{}{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, _ = body["content"].(string)
parsed = parseClashYAML(t, content)
proxies, ok = parsed["proxies"].([]interface{})
if !ok || len(proxies) == 0 {
t.Fatalf("expected proxies in generated content, got %#v", parsed)
}
for _, entry := range proxies {
if proxyMap, ok := entry.(map[string]interface{}); ok {
if name, ok := proxyMap["name"].(string); ok && hasEmojiPrefix(name) {
t.Fatalf("expected leading emoji removed, got %s", name)
}
}
}
}
func contains(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
func TestAPISurgeManagedConfig(t *testing.T) {
config := `server:
host: "127.0.0.1"
port: 26710
logging:
level: "error"
format: "text"
`
app, _, cleanup := setupTestApplicationWithConfig(t, config)
t.Cleanup(cleanup)
server := app.GetHTTPServer()
params := url.Values{}
params.Set("target", "surge")
params.Set("url", "https://mock-subscribe.example.com")
params.Set("upload", "true")
params.Set("interval", "7200")
params.Set("strict", "true")
resp := doRequest(t, server, http.MethodGet, "/api/convert?"+params.Encode())
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for managed config, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
content, _ := body["content"].(string)
if !strings.Contains(content, "#!MANAGED-CONFIG ") {
t.Fatalf("managed config header missing: %s", content)
}
if !strings.Contains(content, "interval=7200") {
t.Fatalf("interval not encoded in managed config header: %s", content)
}
headers, _ := body["headers"].(map[string]interface{})
if headers == nil {
t.Fatalf("headers not returned: %v", body)
}
if val, ok := headers["profile-update-interval"].(string); !ok || val != "2" {
t.Fatalf("expected profile-update-interval=2, got %v", headers["profile-update-interval"])
}
}