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"]) } }