1
0

happ_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. package sub
  2. import (
  3. "encoding/base64"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "testing"
  8. "github.com/gin-gonic/gin"
  9. )
  10. func TestNormalizeHappRouting_Off(t *testing.T) {
  11. got, err := normalizeHappRouting([]byte("happ://routing/off"))
  12. if err != nil {
  13. t.Fatalf("normalizeHappRouting(off) error: %v", err)
  14. }
  15. if got != "happ://routing/off" {
  16. t.Fatalf("normalizeHappRouting(off) = %q, want happ://routing/off", got)
  17. }
  18. }
  19. func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
  20. gin.SetMode(gin.TestMode)
  21. cfg := HappConfig{
  22. AutoDetect: true,
  23. ProviderId: "pid-test-123",
  24. NewUrl: "https://new.example.com/sub",
  25. FallbackUrl: "https://backup.example.com/sub",
  26. SubInfoColor: "primary",
  27. SubInfoText: "Welcome to VIP Network",
  28. SubInfoButtonText: "Telegram",
  29. SubInfoButtonLink: "https://t.me/example",
  30. SubExpire: true,
  31. SubExpireButtonLink: "https://renew.example.com",
  32. NotificationExpire: true,
  33. NoLimit: true,
  34. AlwaysHwid: true,
  35. TunMode: "gvisor",
  36. TunType: "singbox",
  37. ExcludeRoutes: "192.168.1.0/24, 10.0.0.0/8",
  38. ExcludeApns: true,
  39. ColorProfile: "{\"serverRowBackgroundColor\":\n\"#21003D67\"}",
  40. PingType: "http",
  41. AutoConnect: true,
  42. AutoConnectType: "fastest",
  43. PerAppMode: "include",
  44. PerAppList: "com.google.chrome,com.meta.instagram",
  45. }
  46. controller := &SUBController{happConfig: cfg}
  47. recorder := httptest.NewRecorder()
  48. ctx, _ := gin.CreateTestContext(recorder)
  49. ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
  50. ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone; iOS 17.5)")
  51. controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "happ://routing/onadd/existing-rules", false)
  52. h := recorder.Header()
  53. if h.Get("Routing-Enable") != "0" {
  54. t.Fatalf("Routing-Enable = %q, want 0 for Happ with disabled routing", h.Get("Routing-Enable"))
  55. }
  56. if h.Get("Routing") != "happ://routing/onadd/existing-rules" {
  57. t.Fatalf("Routing = %q, want happ://routing/onadd/existing-rules for Happ with configured routing", h.Get("Routing"))
  58. }
  59. if h.Get("Hide-Settings") != "0" {
  60. t.Fatalf("Hide-Settings = %q, want 0 for Happ with disabled hideSettings", h.Get("Hide-Settings"))
  61. }
  62. if h.Get("ProviderID") != "pid-test-123" {
  63. t.Fatalf("ProviderID = %q, want pid-test-123", h.Get("ProviderID"))
  64. }
  65. if h.Get("New-Url") != "https://new.example.com/sub" {
  66. t.Fatalf("New-Url = %q", h.Get("New-Url"))
  67. }
  68. if h.Get("Fallback-Url") != "https://backup.example.com/sub" {
  69. t.Fatalf("Fallback-Url = %q", h.Get("Fallback-Url"))
  70. }
  71. if h.Get("Sub-Info-Color") != "blue" || h.Get("Sub-Info-Text") != "Welcome to VIP Network" {
  72. t.Fatalf("Sub-Info = %s / %s, want blue / Welcome to VIP Network", h.Get("Sub-Info-Color"), h.Get("Sub-Info-Text"))
  73. }
  74. if h.Get("Sub-Info-Button-Text") != "Telegram" || h.Get("Sub-Info-Button-Link") != "https://t.me/example" {
  75. t.Fatalf("Sub-Info button = %s / %s", h.Get("Sub-Info-Button-Text"), h.Get("Sub-Info-Button-Link"))
  76. }
  77. if h.Get("Sub-Expire") != "1" || h.Get("Sub-Expire-Button-Link") != "https://renew.example.com" {
  78. t.Fatalf("Sub-Expire = %s / %s", h.Get("Sub-Expire"), h.Get("Sub-Expire-Button-Link"))
  79. }
  80. if h.Get("Notification-Subs-Expire") != "1" {
  81. t.Fatalf("Notification-Subs-Expire = %q", h.Get("Notification-Subs-Expire"))
  82. }
  83. if h.Get("No-Limit-Enabled") != "1" {
  84. t.Fatalf("No-Limit-Enabled = %q", h.Get("No-Limit-Enabled"))
  85. }
  86. if h.Get("Subscription-Always-Hwid-Enable") != "1" {
  87. t.Fatalf("Subscription-Always-Hwid-Enable = %q", h.Get("Subscription-Always-Hwid-Enable"))
  88. }
  89. if h.Get("Tun-Mode") != "gvisor" || h.Get("Tun-Type") != "singbox" {
  90. t.Fatalf("Tun mode/type = %s / %s", h.Get("Tun-Mode"), h.Get("Tun-Type"))
  91. }
  92. if h.Get("Exclude-Routes") != "192.168.1.0/24, 10.0.0.0/8" || h.Get("Exclude-Apns-Enable") != "true" {
  93. t.Fatalf("Exclude routes/apns = %s / %s", h.Get("Exclude-Routes"), h.Get("Exclude-Apns-Enable"))
  94. }
  95. if wantProfile := "{\"serverRowBackgroundColor\":\"#21003D67\"}"; h.Get("Color-Profile") != wantProfile {
  96. t.Fatalf("Color-Profile = %q, want %q", h.Get("Color-Profile"), wantProfile)
  97. }
  98. if h.Get("Ping-Type") != "proxy" {
  99. t.Fatalf("Ping-Type = %q, want proxy for http alias", h.Get("Ping-Type"))
  100. }
  101. if h.Get("Subscription-Autoconnect") != "1" || h.Get("Subscription-Autoconnect-Type") != "lowestdelay" {
  102. t.Fatalf("Autoconnect = %s / %s, want 1 / lowestdelay for fastest alias", h.Get("Subscription-Autoconnect"), h.Get("Subscription-Autoconnect-Type"))
  103. }
  104. if h.Get("Per-App-Proxy-Mode") != "on" || h.Get("Per-App-Proxy-List") != "com.google.chrome,com.meta.instagram" {
  105. t.Fatalf("Per-App = %s / %s, want on / com.google.chrome,com.meta.instagram", h.Get("Per-App-Proxy-Mode"), h.Get("Per-App-Proxy-List"))
  106. }
  107. }
  108. func TestApplyCommonHeaders_HappRoutingOffDeeplink(t *testing.T) {
  109. gin.SetMode(gin.TestMode)
  110. cfg := HappConfig{AutoDetect: true}
  111. controller := &SUBController{happConfig: cfg}
  112. recorder := httptest.NewRecorder()
  113. ctx, _ := gin.CreateTestContext(recorder)
  114. ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
  115. ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone)")
  116. // When rules is happ://routing/off, Routing-Enable is 0 and Routing is happ://routing/off
  117. controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "happ://routing/off", false)
  118. h := recorder.Header()
  119. if h.Get("Routing-Enable") != "0" {
  120. t.Fatalf("Routing-Enable = %q, want 0 when rules is happ://routing/off", h.Get("Routing-Enable"))
  121. }
  122. if h.Get("Routing") != "happ://routing/off" {
  123. t.Fatalf("Routing = %q, want happ://routing/off", h.Get("Routing"))
  124. }
  125. }
  126. func TestApplyHappHeaders_Gating(t *testing.T) {
  127. gin.SetMode(gin.TestMode)
  128. cfg := HappConfig{
  129. AutoDetect: true,
  130. ProviderId: "pid-secret",
  131. SubInfoText: "Banner",
  132. TunMode: "system",
  133. }
  134. t.Run("non-Happ User-Agent receives no headers", func(t *testing.T) {
  135. recorder := httptest.NewRecorder()
  136. ctx, _ := gin.CreateTestContext(recorder)
  137. ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
  138. ctx.Request.Header.Set("User-Agent", "v2rayNG/1.8.5")
  139. controller := &SUBController{happConfig: cfg}
  140. controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
  141. if got := recorder.Header().Get("ProviderID"); got != "" {
  142. t.Fatalf("ProviderID emitted to non-Happ client: %q", got)
  143. }
  144. if got := recorder.Header().Get("Sub-Info-Text"); got != "" {
  145. t.Fatalf("Sub-Info-Text emitted to non-Happ client: %q", got)
  146. }
  147. if got := recorder.Header().Get("Tun-Mode"); got != "" {
  148. t.Fatalf("Tun-Mode emitted to non-Happ client: %q", got)
  149. }
  150. })
  151. t.Run("AutoDetect disabled suppresses Happ headers", func(t *testing.T) {
  152. recorder := httptest.NewRecorder()
  153. ctx, _ := gin.CreateTestContext(recorder)
  154. ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
  155. ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (Android)")
  156. disabledCfg := cfg
  157. disabledCfg.AutoDetect = false
  158. controller := &SUBController{happConfig: disabledCfg}
  159. controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
  160. if got := recorder.Header().Get("ProviderID"); got != "" {
  161. t.Fatalf("ProviderID emitted when AutoDetect is false: %q", got)
  162. }
  163. if got := recorder.Header().Get("Sub-Info-Text"); got != "" {
  164. t.Fatalf("Sub-Info-Text emitted when AutoDetect is false: %q", got)
  165. }
  166. // happ.su documents routing-enable 0/false as "disables routing
  167. // globally", so it must stay behind the same opt-in as the rest.
  168. if got := recorder.Header().Get("Routing-Enable"); got != "" {
  169. t.Fatalf("Routing-Enable emitted when AutoDetect is false: %q", got)
  170. }
  171. if got := recorder.Header().Get("Hide-Settings"); got != "" {
  172. t.Fatalf("Hide-Settings emitted when AutoDetect is false: %q", got)
  173. }
  174. if got := recorder.Header().Get("Routing"); got != "" {
  175. t.Fatalf("Routing emitted when AutoDetect is false: %q", got)
  176. }
  177. })
  178. }
  179. func TestApplyHappHeaders_Aliases(t *testing.T) {
  180. gin.SetMode(gin.TestMode)
  181. tests := []struct {
  182. name string
  183. cfg HappConfig
  184. wantHeader string
  185. wantValue string
  186. }{
  187. {
  188. name: "color warning maps to red",
  189. cfg: HappConfig{AutoDetect: true, SubInfoText: "Alert", SubInfoColor: "warning"},
  190. wantHeader: "Sub-Info-Color",
  191. wantValue: "red",
  192. },
  193. {
  194. name: "color danger maps to red",
  195. cfg: HappConfig{AutoDetect: true, SubInfoText: "Alert", SubInfoColor: "danger"},
  196. wantHeader: "Sub-Info-Color",
  197. wantValue: "red",
  198. },
  199. {
  200. name: "color success maps to green",
  201. cfg: HappConfig{AutoDetect: true, SubInfoText: "Ok", SubInfoColor: "success"},
  202. wantHeader: "Sub-Info-Color",
  203. wantValue: "green",
  204. },
  205. {
  206. name: "autoconnect last maps to lastused",
  207. cfg: HappConfig{AutoDetect: true, AutoConnect: true, AutoConnectType: "last"},
  208. wantHeader: "Subscription-Autoconnect-Type",
  209. wantValue: "lastused",
  210. },
  211. {
  212. name: "per-app exclude maps to bypass",
  213. cfg: HappConfig{AutoDetect: true, PerAppMode: "exclude", PerAppList: "app.id"},
  214. wantHeader: "Per-App-Proxy-Mode",
  215. wantValue: "bypass",
  216. },
  217. }
  218. for _, tc := range tests {
  219. t.Run(tc.name, func(t *testing.T) {
  220. recorder := httptest.NewRecorder()
  221. ctx, _ := gin.CreateTestContext(recorder)
  222. ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
  223. ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iOS)")
  224. controller := &SUBController{happConfig: tc.cfg}
  225. controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
  226. if got := recorder.Header().Get(tc.wantHeader); got != tc.wantValue {
  227. t.Fatalf("%s = %q, want %q", tc.wantHeader, got, tc.wantValue)
  228. }
  229. })
  230. }
  231. }
  232. func TestAppendHappServerDescription(t *testing.T) {
  233. desc := "VIP Server"
  234. encoded := base64.StdEncoding.EncodeToString([]byte(desc))
  235. got := appendHappServerDescription("My Node", desc)
  236. want := "My Node?serverDescription=" + encoded
  237. if got != want {
  238. t.Fatalf("appendHappServerDescription = %q, want %q", got, want)
  239. }
  240. if gotEmpty := appendHappServerDescription("My Node", ""); gotEmpty != "My Node" {
  241. t.Fatalf("appendHappServerDescription with empty desc = %q, want My Node", gotEmpty)
  242. }
  243. }
  244. func TestAppendQueryAndFragment_PreservesServerDescription(t *testing.T) {
  245. desc := "Fast Server"
  246. encoded := base64.StdEncoding.EncodeToString([]byte(desc))
  247. t.Run("preserves serverDescription with encoded title", func(t *testing.T) {
  248. fragment := "Server 01?serverDescription=" + encoded
  249. link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
  250. want := "vless://user@host:443#Server%2001?serverDescription=" + encoded
  251. if link != want {
  252. t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
  253. }
  254. })
  255. t.Run("properly escapes remark containing literal question mark without serverDescription", func(t *testing.T) {
  256. fragment := "Fast? Server"
  257. link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
  258. want := "vless://user@host:443#Fast%3F%20Server"
  259. if link != want {
  260. t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
  261. }
  262. })
  263. t.Run("properly escapes remark containing literal question mark with serverDescription", func(t *testing.T) {
  264. fragment := "Fast? Server?serverDescription=" + encoded
  265. link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
  266. want := "vless://user@host:443#Fast%3F%20Server?serverDescription=" + encoded
  267. if link != want {
  268. t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
  269. }
  270. })
  271. t.Run("escapes fragment when serverDescription tail contains invalid base64", func(t *testing.T) {
  272. fragment := "Node 1?serverDescription=not-base64!!!"
  273. link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
  274. want := "vless://user@host:443#Node%201%3FserverDescription%3Dnot-base64%21%21%21"
  275. if link != want {
  276. t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
  277. }
  278. })
  279. t.Run("escapes fragment when serverDescription tail contains newline injection", func(t *testing.T) {
  280. fragment := "Node 1?serverDescription=" + encoded + "\nevil://inject"
  281. link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
  282. if strings.Contains(link, "\n") {
  283. t.Fatalf("appendQueryAndFragment emitted raw newline: %q", link)
  284. }
  285. })
  286. }
  287. func TestIsHappClient(t *testing.T) {
  288. matching := []string{
  289. "Happ/1.2.0 (iPhone; iOS 17.5)",
  290. "happ/2.0",
  291. "happ",
  292. "HAPP/1.0",
  293. "Mozilla/5.0 Happ/1.0",
  294. }
  295. for _, ua := range matching {
  296. if !IsHappClient(ua) {
  297. t.Errorf("IsHappClient(%q) = false, want true", ua)
  298. }
  299. }
  300. nonMatching := []string{
  301. "Happy/2.0",
  302. "happier-client",
  303. "Happening/1.0",
  304. "unhappy",
  305. "v2rayNG/1.8.5",
  306. "",
  307. }
  308. for _, ua := range nonMatching {
  309. if IsHappClient(ua) {
  310. t.Errorf("IsHappClient(%q) = true, want false", ua)
  311. }
  312. }
  313. }