Files
navi/internal/subscription/fetch_test.go
T
NavisandCursor 50f7f71393 Add site contacts/FAQ and accept AWG 2.0 / naive pastes in Add config.
ImportSubscription no longer requires only http(s): paste awg://, AWG .conf/JSON, or naive links; subscription parser also reads hosted AWG and Clash wireguard/naive. Site lists support emails and FAQ; Store listing texts/tools updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:40:13 +03:00

355 lines
9.3 KiB
Go

package subscription
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseBodyPlain(t *testing.T) {
body := `# comment
hysteria2://pass@ex.com:443/?sni=ex.com&obfs=salamander&obfs-password=x#EU
naive+https://u:p@n.example.com#naive1
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("got %d items", len(items))
}
}
func TestParseBodyBase64(t *testing.T) {
plain := "hysteria2://secret@host.example:443/?sni=host.example#HY\nhttps://user:pass@naive.example.com#NV\n"
body := base64.StdEncoding.EncodeToString([]byte(plain))
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("got %d items: %+v", len(items), items)
}
}
// TestParseBodyTolerant: one broken entry must never abort the import —
// valid links are kept, unsupported/broken lines are skipped with warnings.
func TestParseBodyTolerant(t *testing.T) {
body := `vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@cz.example.com:443?encryption=none&security=tls&type=tcp#CZ
hysteria2://secret@de.example.com:443/?sni=de.example.com#DE-HY2
https://api.example.com/ZKszcVcC3xbWb8qj
foobar://what@is.this:1
# comment line
`
res, err := ParseBodyDetailed(body)
if err != nil {
t.Fatal(err)
}
if len(res.Items) != 2 {
t.Fatalf("imported %d items, want 2: %+v", len(res.Items), res.Items)
}
if res.Items[0].Protocol != "vless" || res.Items[1].Protocol != "hysteria2" {
t.Fatalf("protocols: %s, %s", res.Items[0].Protocol, res.Items[1].Protocol)
}
// https:// without userinfo + unknown scheme → skipped; empty/comment lines are not counted.
if res.Skipped != 2 {
t.Fatalf("skipped %d, want 2 (%v)", res.Skipped, res.Warnings)
}
if len(res.Warnings) != 2 {
t.Fatalf("warnings: %v", res.Warnings)
}
}
func TestParseBodyZeroValidListsReasons(t *testing.T) {
body := "https://api.example.com/sub-token-line\nfoobar://x@y:1\n"
res, err := ParseBodyDetailed(body)
if err == nil {
t.Fatal("want error for zero valid entries")
}
if res.Skipped != 2 {
t.Fatalf("skipped %d, want 2", res.Skipped)
}
if !strings.Contains(err.Error(), "2 записей пропущено") {
t.Fatalf("error should list skip count: %v", err)
}
}
func TestParseUserInfoHeader(t *testing.T) {
info := ParseUserInfoHeader("upload=0; download=5679561725; total=53687091200000; expire=1799366585")
if info == nil {
t.Fatal("nil info")
}
if info.Download != 5679561725 || info.Total != 53687091200000 || info.Expire != 1799366585 || info.Upload != 0 {
t.Fatalf("bad parse: %+v", info)
}
if ParseUserInfoHeader("") != nil {
t.Fatal("empty header must give nil")
}
if ParseUserInfoHeader("garbage") != nil {
t.Fatal("garbage header must give nil")
}
}
// Remnawave returns Clash YAML for Clash-like UAs and raw base64 links otherwise.
// Fetch must obtain the full server list (plain UA) and the userinfo header.
func TestFetchPrefersPlainUA(t *testing.T) {
links := "vless://7e1c0fbf-1758-4781-97f6-ae8715ed3f60@cz.example.com:443?encryption=none&security=tls&type=tcp#CZ\n" +
"trojan://pass@no.example.com:20000?security=tls&type=ws#NO\n" +
"hysteria2://secret@de.example.com:443/?sni=de.example.com#DE\n" +
"ss://YWVzLTI1Ni1nY206cGFzcw@ss.example.com:8388#SS\n"
clashYAML := "proxies:\n - name: only-hy2\n type: hysteria2\n server: de.example.com\n port: 443\n password: secret\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Subscription-Userinfo", "upload=1; download=2; total=100; expire=1799366585")
if strings.Contains(strings.ToLower(r.Header.Get("User-Agent")), "clash") {
_, _ = w.Write([]byte(clashYAML))
return
}
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString([]byte(links))))
}))
defer srv.Close()
res, err := Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatal(err)
}
if len(res.Items) != 3 {
t.Fatalf("imported %d, want 3 (vless+trojan+hy2): %+v", len(res.Items), res.Items)
}
if res.Skipped != 1 {
t.Fatalf("skipped %d, want 1 (ss://)", res.Skipped)
}
if res.Info == nil || res.Info.Expire != 1799366585 || res.Info.Total != 100 {
t.Fatalf("info: %+v", res.Info)
}
}
// Indented Clash proxies with nested lists (alpn) must split into separate blocks.
func TestParseBodyClashYAMLIndented(t *testing.T) {
body := `proxies:
- name: DE HY2
type: hysteria2
server: de.example.com
port: 443
udp: true
password: secret1
sni: de.example.com
alpn:
- h3
- name: NO HY2
type: hysteria2
server: no.example.com
port: 9000
password: secret2
alpn:
- h3
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("got %d items, want 2: %+v", len(items), items)
}
for i, host := range []string{"de.example.com:443", "no.example.com:9000"} {
if !strings.Contains(items[i].URI, host) {
t.Fatalf("item %d uri %s missing %s", i, items[i].URI, host)
}
}
for i, pass := range []string{"secret1", "secret2"} {
if !strings.Contains(items[i].URI, pass) {
t.Fatalf("item %d uri %s missing password %s", i, items[i].URI, pass)
}
}
}
func TestParseBodyClashYAML(t *testing.T) {
body := `
proxies:
- name: "DE HY2"
type: hysteria2
server: de.example.com
port: 443
password: secret
sni: de.example.com
obfs: salamander
obfs-password: obfspass
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("got %d", len(items))
}
if items[0].Protocol != "hysteria2" {
t.Fatalf("proto %s", items[0].Protocol)
}
if !strings.Contains(items[0].URI, "de.example.com:443") {
t.Fatalf("uri %s", items[0].URI)
}
}
func TestParseBodyAWGConf(t *testing.T) {
body := `[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.8.0.2/32
DNS = 1.1.1.1
Jc = 4
Jmin = 40
Jmax = 70
H1 = 1
H2 = 2
H3 = 3
H4 = 4
I1 = <r 128>
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Endpoint = 203.0.113.10:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("got %d items: %+v", len(items), items)
}
if items[0].Protocol != "awg" {
t.Fatalf("proto %s", items[0].Protocol)
}
if !strings.Contains(items[0].URI, "Jc = 4") || !strings.Contains(items[0].URI, "203.0.113.10:51820") {
t.Fatalf("uri missing AWG2 fields: %s", items[0].URI)
}
}
func TestParseBodyAmneziaJSON(t *testing.T) {
body := `{
"client_priv_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"server_pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=",
"client_ip": "10.8.0.2/32",
"hostName": "203.0.113.10",
"port": "51820",
"Jc": 4,
"Jmin": 10,
"Jmax": 50,
"H1": "1",
"H2": "2",
"H3": "3",
"H4": "4"
}`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Protocol != "awg" {
t.Fatalf("got %+v", items)
}
if !strings.Contains(items[0].URI, "203.0.113.10:51820") {
t.Fatalf("uri %s", items[0].URI)
}
}
func TestParseBodyClashWireguardAWG20(t *testing.T) {
body := `proxies:
- name: AWG-EU
type: wireguard
server: 203.0.113.20
port: 51820
ip: 10.8.0.2/32
private-key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
public-key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
allowed-ips: ['0.0.0.0/0']
dns: [1.1.1.1]
jc: 4
jmin: 40
jmax: 70
h1: 1
h2: 2
h3: 3
h4: 4
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("got %d: %+v", len(items), items)
}
if items[0].Protocol != "awg" {
t.Fatalf("proto %s", items[0].Protocol)
}
if items[0].Name != "AWG-EU" {
t.Fatalf("name %q", items[0].Name)
}
if !strings.Contains(items[0].URI, "Jc = 4") || !strings.Contains(items[0].URI, "203.0.113.20:51820") {
t.Fatalf("uri %s", items[0].URI)
}
}
func TestParseBodyClashNaive(t *testing.T) {
body := `proxies:
- name: Naive-HTTPS
type: naive
server: naive.example.com
port: 443
username: user
password: secret
- name: Naive-QUIC
type: naive
server: quic.example.com
port: 443
username: u2
password: p2
udp: true
`
items, err := ParseBody(body)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("got %d: %+v", len(items), items)
}
if items[0].Protocol != "naive" || items[1].Protocol != "naive" {
t.Fatalf("protos %s %s", items[0].Protocol, items[1].Protocol)
}
if !strings.Contains(items[0].URI, "https://") || !strings.Contains(items[0].URI, "user:secret@naive.example.com") {
t.Fatalf("https uri %s", items[0].URI)
}
if !strings.Contains(items[1].URI, "quic://") || !strings.Contains(items[1].URI, "u2:p2@quic.example.com") {
t.Fatalf("quic uri %s", items[1].URI)
}
}
func TestFetchAWGConfOverHTTP(t *testing.T) {
conf := `[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.8.0.2/32
Jc = 3
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Endpoint = 198.51.100.7:51820
AllowedIPs = 0.0.0.0/0
`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, conf)
}))
defer srv.Close()
res, err := Fetch(context.Background(), srv.URL+"/awg.conf")
if err != nil {
t.Fatal(err)
}
if len(res.Items) != 1 || res.Items[0].Protocol != "awg" {
t.Fatalf("got %+v", res.Items)
}
}