outbound_subscription_test.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. package service
  2. import (
  3. "bytes"
  4. "errors"
  5. "testing"
  6. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  7. "github.com/mhsanaei/3x-ui/v3/internal/util/link"
  8. )
  9. func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
  10. t.Run("accepts body at the limit", func(t *testing.T) {
  11. want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))
  12. got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(want))
  13. if err != nil {
  14. t.Fatalf("readBoundedOutboundSubscriptionBody: %v", err)
  15. }
  16. if !bytes.Equal(got, want) {
  17. t.Fatalf("body mismatch: got %d bytes, want %d", len(got), len(want))
  18. }
  19. })
  20. t.Run("rejects body over the limit", func(t *testing.T) {
  21. body := bytes.Repeat([]byte("b"), int(maxOutboundSubscriptionBytes)+1)
  22. got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(body))
  23. if !errors.Is(err, errOutboundSubscriptionBodyTooLarge) {
  24. t.Fatalf("error = %v, want errOutboundSubscriptionBodyTooLarge", err)
  25. }
  26. if got != nil {
  27. t.Fatalf("oversized body returned %d bytes, want nil", len(got))
  28. }
  29. })
  30. }
  31. func TestDefaultPrefixNumber(t *testing.T) {
  32. mk := func(id int, prefix string) *model.OutboundSubscription {
  33. return &model.OutboundSubscription{Id: id, TagPrefix: prefix}
  34. }
  35. cases := []struct {
  36. name string
  37. subs []*model.OutboundSubscription
  38. excludeId int
  39. want int
  40. }{
  41. {"no subscriptions starts at 1", nil, 0, 1},
  42. {"sequential prefixes give the next", []*model.OutboundSubscription{mk(1, "sub1-"), mk(2, "sub2-")}, 0, 3},
  43. {"reuses the lowest freed number", []*model.OutboundSubscription{mk(2, "sub2-")}, 0, 1},
  44. {"legacy blank prefix reserves its id", []*model.OutboundSubscription{mk(1, ""), mk(5, "sub3-")}, 0, 2},
  45. {"custom prefixes are ignored", []*model.OutboundSubscription{mk(1, "hk-"), mk(2, "jp-")}, 0, 1},
  46. {"excludes the edited subscription", []*model.OutboundSubscription{mk(5, "sub2-")}, 5, 1},
  47. }
  48. for _, c := range cases {
  49. t.Run(c.name, func(t *testing.T) {
  50. if got := defaultPrefixNumber(c.subs, c.excludeId); got != c.want {
  51. t.Fatalf("got %d, want %d", got, c.want)
  52. }
  53. })
  54. }
  55. }
  56. func TestAssignStableTags(t *testing.T) {
  57. t.Run("reuses the tag mapped to a known identity", func(t *testing.T) {
  58. parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
  59. prev := map[string]string{"id-abc": "sub1-keepme"}
  60. got := assignStableTags(parsed, []string{"id-abc"}, prev, nil, 1, "")
  61. if got[0] != "sub1-keepme" {
  62. t.Fatalf("got %q, want sub1-keepme", got[0])
  63. }
  64. if parsed[0]["tag"] != "sub1-keepme" {
  65. t.Fatalf("tag was not written back into the outbound: %v", parsed[0]["tag"])
  66. }
  67. })
  68. t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
  69. parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
  70. got := assignStableTags(parsed, []string{"id-new"}, map[string]string{}, map[int]string{0: "sub1-oldpos"}, 1, "")
  71. if got[0] != "sub1-oldpos" {
  72. t.Fatalf("got %q, want sub1-oldpos", got[0])
  73. }
  74. })
  75. t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
  76. parsed := []link.Outbound{{"tag": "Tokyo"}}
  77. got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")
  78. want := link.SuggestTag("sub7-", "Tokyo", 0)
  79. if got[0] != want {
  80. t.Fatalf("got %q, want %q", got[0], want)
  81. }
  82. })
  83. t.Run("uses a custom prefix for fresh tags", func(t *testing.T) {
  84. parsed := []link.Outbound{{"tag": "Tokyo"}}
  85. got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 1, "hk-")
  86. want := link.SuggestTag("hk-", "Tokyo", 0)
  87. if got[0] != want {
  88. t.Fatalf("got %q, want %q", got[0], want)
  89. }
  90. })
  91. t.Run("disambiguates colliding tags with a -N suffix", func(t *testing.T) {
  92. parsed := []link.Outbound{{"tag": "Same"}, {"tag": "Same"}}
  93. got := assignStableTags(parsed, []string{"id1", "id2"}, nil, nil, 1, "p-")
  94. base := link.SuggestTag("p-", "Same", 0)
  95. if got[0] != base {
  96. t.Fatalf("got[0] = %q, want %q", got[0], base)
  97. }
  98. if got[1] != base+"-1" {
  99. t.Fatalf("got[1] = %q, want %q", got[1], base+"-1")
  100. }
  101. })
  102. }
  103. // TestOutboundsContainTag covers the guard that ensures the outbound under test
  104. // is present in the HTTP-probe config. Subscription outbounds aren't part of the
  105. // template outbounds the frontend sends as allOutbounds, so the probe must append
  106. // the tested outbound when its tag is missing (otherwise burstObservatory has
  107. // nothing to probe and every subscription test times out).
  108. func TestOutboundsContainTag(t *testing.T) {
  109. template := []any{
  110. map[string]any{"tag": "direct", "protocol": "freedom"},
  111. map[string]any{"tag": "blocked", "protocol": "blackhole"},
  112. }
  113. if !outboundsContainTag(template, "direct") {
  114. t.Fatal("expected tag 'direct' to be found")
  115. }
  116. if outboundsContainTag(template, "sub1-tokyo") {
  117. t.Fatal("expected subscription tag to be absent from template outbounds")
  118. }
  119. if outboundsContainTag(nil, "anything") {
  120. t.Fatal("expected empty slice to contain no tags")
  121. }
  122. // Tolerates non-map / untagged entries without panicking.
  123. mixed := []any{"not-a-map", map[string]any{"protocol": "freedom"}}
  124. if outboundsContainTag(mixed, "direct") {
  125. t.Fatal("expected no match among untagged/non-map entries")
  126. }
  127. }
  128. // TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
  129. // when fetching subscription URLs. All rejected cases use literal IPs or bad
  130. // schemes so the test never performs real DNS resolution.
  131. func TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes(t *testing.T) {
  132. rejected := []string{
  133. "http://127.0.0.1/sub", // loopback
  134. "http://10.0.0.1/x", // private
  135. "http://192.168.1.1", // private
  136. "http://169.254.169.254/latest/meta-data", // link-local (cloud metadata)
  137. "http://[::1]:8080/sub", // IPv6 loopback
  138. "http://0.0.0.0", // unspecified
  139. "ftp://example.com/x", // unsupported scheme
  140. "file:///etc/passwd", // unsupported scheme
  141. }
  142. for _, raw := range rejected {
  143. if _, err := SanitizePublicHTTPURL(raw, false); err == nil {
  144. t.Errorf("expected %q to be rejected, got nil error", raw)
  145. }
  146. }
  147. t.Run("allows a public literal IP without DNS", func(t *testing.T) {
  148. got, err := SanitizePublicHTTPURL("http://8.8.8.8/sub", false)
  149. if err != nil {
  150. t.Fatalf("unexpected error: %v", err)
  151. }
  152. if got != "http://8.8.8.8/sub" {
  153. t.Fatalf("got %q, want http://8.8.8.8/sub", got)
  154. }
  155. })
  156. }
  157. // outboundsContainTag mirrors the small helper in the outbound subpackage so
  158. // these subscription tests can assert on tag presence without importing it.
  159. func outboundsContainTag(outbounds []any, tag string) bool {
  160. for _, ob := range outbounds {
  161. if m, ok := ob.(map[string]any); ok {
  162. if t, _ := m["tag"].(string); t == tag {
  163. return true
  164. }
  165. }
  166. }
  167. return false
  168. }