Files
3x-ui/internal/web/service/outbound_subscription_test.go
T

183 lines
6.6 KiB
Go

package service
import (
"bytes"
"errors"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
)
func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
t.Run("accepts body at the limit", func(t *testing.T) {
want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))
got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(want))
if err != nil {
t.Fatalf("readBoundedOutboundSubscriptionBody: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("body mismatch: got %d bytes, want %d", len(got), len(want))
}
})
t.Run("rejects body over the limit", func(t *testing.T) {
body := bytes.Repeat([]byte("b"), int(maxOutboundSubscriptionBytes)+1)
got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(body))
if !errors.Is(err, errOutboundSubscriptionBodyTooLarge) {
t.Fatalf("error = %v, want errOutboundSubscriptionBodyTooLarge", err)
}
if got != nil {
t.Fatalf("oversized body returned %d bytes, want nil", len(got))
}
})
}
func TestDefaultPrefixNumber(t *testing.T) {
mk := func(id int, prefix string) *model.OutboundSubscription {
return &model.OutboundSubscription{Id: id, TagPrefix: prefix}
}
cases := []struct {
name string
subs []*model.OutboundSubscription
excludeId int
want int
}{
{"no subscriptions starts at 1", nil, 0, 1},
{"sequential prefixes give the next", []*model.OutboundSubscription{mk(1, "sub1-"), mk(2, "sub2-")}, 0, 3},
{"reuses the lowest freed number", []*model.OutboundSubscription{mk(2, "sub2-")}, 0, 1},
{"legacy blank prefix reserves its id", []*model.OutboundSubscription{mk(1, ""), mk(5, "sub3-")}, 0, 2},
{"custom prefixes are ignored", []*model.OutboundSubscription{mk(1, "hk-"), mk(2, "jp-")}, 0, 1},
{"excludes the edited subscription", []*model.OutboundSubscription{mk(5, "sub2-")}, 5, 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := defaultPrefixNumber(c.subs, c.excludeId); got != c.want {
t.Fatalf("got %d, want %d", got, c.want)
}
})
}
}
func TestAssignStableTags(t *testing.T) {
t.Run("reuses the tag mapped to a known identity", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
prev := map[string]string{"id-abc": "sub1-keepme"}
got := assignStableTags(parsed, []string{"id-abc"}, prev, nil, 1, "")
if got[0] != "sub1-keepme" {
t.Fatalf("got %q, want sub1-keepme", got[0])
}
if parsed[0]["tag"] != "sub1-keepme" {
t.Fatalf("tag was not written back into the outbound: %v", parsed[0]["tag"])
}
})
t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
got := assignStableTags(parsed, []string{"id-new"}, map[string]string{}, map[int]string{0: "sub1-oldpos"}, 1, "")
if got[0] != "sub1-oldpos" {
t.Fatalf("got %q, want sub1-oldpos", got[0])
}
})
t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "Tokyo"}}
got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")
want := link.SuggestTag("sub7-", "Tokyo", 0)
if got[0] != want {
t.Fatalf("got %q, want %q", got[0], want)
}
})
t.Run("uses a custom prefix for fresh tags", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "Tokyo"}}
got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 1, "hk-")
want := link.SuggestTag("hk-", "Tokyo", 0)
if got[0] != want {
t.Fatalf("got %q, want %q", got[0], want)
}
})
t.Run("disambiguates colliding tags with a -N suffix", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "Same"}, {"tag": "Same"}}
got := assignStableTags(parsed, []string{"id1", "id2"}, nil, nil, 1, "p-")
base := link.SuggestTag("p-", "Same", 0)
if got[0] != base {
t.Fatalf("got[0] = %q, want %q", got[0], base)
}
if got[1] != base+"-1" {
t.Fatalf("got[1] = %q, want %q", got[1], base+"-1")
}
})
}
// TestOutboundsContainTag covers the guard that ensures the outbound under test
// is present in the HTTP-probe config. Subscription outbounds aren't part of the
// template outbounds the frontend sends as allOutbounds, so the probe must append
// the tested outbound when its tag is missing (otherwise burstObservatory has
// nothing to probe and every subscription test times out).
func TestOutboundsContainTag(t *testing.T) {
template := []any{
map[string]any{"tag": "direct", "protocol": "freedom"},
map[string]any{"tag": "blocked", "protocol": "blackhole"},
}
if !outboundsContainTag(template, "direct") {
t.Fatal("expected tag 'direct' to be found")
}
if outboundsContainTag(template, "sub1-tokyo") {
t.Fatal("expected subscription tag to be absent from template outbounds")
}
if outboundsContainTag(nil, "anything") {
t.Fatal("expected empty slice to contain no tags")
}
// Tolerates non-map / untagged entries without panicking.
mixed := []any{"not-a-map", map[string]any{"protocol": "freedom"}}
if outboundsContainTag(mixed, "direct") {
t.Fatal("expected no match among untagged/non-map entries")
}
}
// TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
// when fetching subscription URLs. All rejected cases use literal IPs or bad
// schemes so the test never performs real DNS resolution.
func TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes(t *testing.T) {
rejected := []string{
"http://127.0.0.1/sub", // loopback
"http://10.0.0.1/x", // private
"http://192.168.1.1", // private
"http://169.254.169.254/latest/meta-data", // link-local (cloud metadata)
"http://[::1]:8080/sub", // IPv6 loopback
"http://0.0.0.0", // unspecified
"ftp://example.com/x", // unsupported scheme
"file:///etc/passwd", // unsupported scheme
}
for _, raw := range rejected {
if _, err := SanitizePublicHTTPURL(raw, false); err == nil {
t.Errorf("expected %q to be rejected, got nil error", raw)
}
}
t.Run("allows a public literal IP without DNS", func(t *testing.T) {
got, err := SanitizePublicHTTPURL("http://8.8.8.8/sub", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "http://8.8.8.8/sub" {
t.Fatalf("got %q, want http://8.8.8.8/sub", got)
}
})
}
// outboundsContainTag mirrors the small helper in the outbound subpackage so
// these subscription tests can assert on tag presence without importing it.
func outboundsContainTag(outbounds []any, tag string) bool {
for _, ob := range outbounds {
if m, ok := ob.(map[string]any); ok {
if t, _ := m["tag"].(string); t == tag {
return true
}
}
}
return false
}