1
0

calendar_expire_test.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. package sub
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "time"
  10. "github.com/gin-gonic/gin"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database"
  12. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  13. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  14. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  15. )
  16. func TestSubscriptionCalendarExpireInclusive(t *testing.T) {
  17. gin.SetMode(gin.TestMode)
  18. tests := []struct {
  19. name string
  20. setting string
  21. zone string
  22. boundary string
  23. resetDay int
  24. trafficDay int
  25. wantSnap bool
  26. }{
  27. {"default unchanged", "", "UTC", "2030-10-01T00:00:00Z", 1, 1, false},
  28. {"disabled unchanged", "false", "UTC", "2030-10-01T00:00:00Z", 1, 1, false},
  29. {"30 day month", "true", "UTC", "2030-10-01T00:00:00Z", 1, 1, true},
  30. {"31 day month", "true", "UTC", "2030-11-01T00:00:00Z", 1, 1, true},
  31. {"non leap February", "true", "UTC", "2030-03-01T00:00:00Z", 1, 1, true},
  32. {"leap February", "true", "UTC", "2028-03-01T00:00:00Z", 1, 1, true},
  33. {"Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00+08:00", 1, 1, true},
  34. {"New York daylight time", "true", "America/New_York", "2030-10-01T00:00:00-04:00", 1, 1, true},
  35. {"New York standard time", "true", "America/New_York", "2030-02-01T00:00:00-05:00", 1, 1, true},
  36. {"Havana first midnight", "true", "America/Havana", "2026-11-01T00:00:00-04:00", 1, 1, true},
  37. {"Havana repeated midnight", "true", "America/Havana", "2026-11-01T00:00:00-05:00", 1, 1, false},
  38. {"UTC midnight is not Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00Z", 1, 1, false},
  39. {"midday unchanged", "true", "UTC", "2030-10-01T12:00:00Z", 1, 1, false},
  40. {"other midnight unchanged", "true", "UTC", "2030-10-02T00:00:00Z", 1, 1, false},
  41. {"fractional midnight unchanged", "true", "UTC", "2030-10-01T00:00:00.001Z", 1, 1, false},
  42. {"legacy inclusive input unchanged", "true", "UTC", "2030-09-30T23:59:59Z", 1, 1, false},
  43. {"interval renewal unchanged", "true", "UTC", "2030-10-01T00:00:00Z", 0, 0, false},
  44. {"other billing day unchanged", "true", "UTC", "2030-10-01T00:00:00Z", 31, 31, false},
  45. {"client calendar overrides stale traffic", "true", "UTC", "2030-10-01T00:00:00Z", 1, 0, true},
  46. {"client interval overrides stale traffic", "true", "UTC", "2030-10-01T00:00:00Z", 0, 1, false},
  47. {"unlimited unchanged", "true", "UTC", "", 1, 1, false},
  48. }
  49. for _, tt := range tests {
  50. t.Run(tt.name, func(t *testing.T) {
  51. seedSubDB(t)
  52. db := database.GetDB()
  53. if err := db.Create(&model.Setting{Key: "timeLocation", Value: tt.zone}).Error; err != nil {
  54. t.Fatal(err)
  55. }
  56. if tt.setting != "" {
  57. if err := db.Create(&model.Setting{Key: "subCalendarExpireInclusive", Value: tt.setting}).Error; err != nil {
  58. t.Fatal(err)
  59. }
  60. }
  61. var expiry int64
  62. if tt.boundary != "" {
  63. at, err := time.Parse(time.RFC3339Nano, tt.boundary)
  64. if err != nil {
  65. t.Fatal(err)
  66. }
  67. expiry = at.UnixMilli()
  68. }
  69. seedSubProtocolInbound(t, "calendar", "monthly", 4931, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
  70. if err := db.Model(&model.ClientRecord{}).Where("email = ?", "monthly@e").Updates(map[string]any{
  71. "expiry_time": expiry, "reset_day": tt.resetDay,
  72. }).Error; err != nil {
  73. t.Fatal(err)
  74. }
  75. // Node snapshots may omit limits; the clients table still owns the calendar.
  76. if err := db.Create(&xray.ClientTraffic{
  77. Email: "monthly@e", Enable: true, Up: 11, Down: 22, ResetDay: tt.trafficDay, ResetCount: 7,
  78. }).Error; err != nil {
  79. t.Fatal(err)
  80. }
  81. router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
  82. wantExpiry := expiry / 1000
  83. if tt.wantSnap {
  84. wantExpiry--
  85. }
  86. wantHeader := fmt.Sprintf("upload=11; download=22; total=0; expire=%d", wantExpiry)
  87. for _, path := range []string{"/sub/calendar", "/json/calendar", "/clash/calendar", "/mihomo/calendar", "/clash-legacy/calendar"} {
  88. resp := httptest.NewRecorder()
  89. router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil))
  90. if resp.Code != http.StatusOK {
  91. t.Fatalf("GET %s: status=%d body=%s", path, resp.Code, resp.Body.String())
  92. }
  93. if got := resp.Header().Get("Subscription-Userinfo"); got != wantHeader {
  94. t.Fatalf("GET %s: userinfo=%q, want %q", path, got, wantHeader)
  95. }
  96. }
  97. resp := httptest.NewRecorder()
  98. router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/calendar?format=info", nil))
  99. var info struct {
  100. Expire int64 `json:"expire"`
  101. }
  102. if resp.Code != http.StatusOK {
  103. t.Fatalf("info status=%d body=%s", resp.Code, resp.Body.String())
  104. }
  105. if err := json.Unmarshal(resp.Body.Bytes(), &info); err != nil {
  106. t.Fatal(err)
  107. }
  108. if info.Expire != expiry/1000 {
  109. t.Fatalf("info cutoff=%d, want canonical %d", info.Expire, expiry/1000)
  110. }
  111. var client model.ClientRecord
  112. var traffic xray.ClientTraffic
  113. if err := db.Where("email = ?", "monthly@e").First(&client).Error; err != nil {
  114. t.Fatal(err)
  115. }
  116. if err := db.Where("email = ?", "monthly@e").First(&traffic).Error; err != nil {
  117. t.Fatal(err)
  118. }
  119. if client.ExpiryTime != expiry || traffic.ResetCount != 7 || traffic.Up != 11 || traffic.Down != 22 {
  120. t.Fatalf("subscription presentation mutated scheduling/accounting: client=%+v traffic=%+v", client, traffic)
  121. }
  122. })
  123. }
  124. }
  125. func TestSubscriptionCalendarExpireInclusiveMixedClients(t *testing.T) {
  126. tests := []struct {
  127. name string
  128. days [2]int
  129. different bool
  130. wantExpiry int64
  131. }{
  132. {"calendar then interval", [2]int{1, 0}, false, 1917043200},
  133. {"interval then calendar", [2]int{0, 1}, false, 1917043200},
  134. {"same calendar", [2]int{1, 1}, false, 1917043199},
  135. {"different cutoffs", [2]int{1, 1}, true, 0},
  136. }
  137. for _, tt := range tests {
  138. t.Run(tt.name, func(t *testing.T) {
  139. seedSubDB(t)
  140. db := database.GetDB()
  141. for key, value := range map[string]string{"timeLocation": "UTC", "subCalendarExpireInclusive": "true"} {
  142. if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
  143. t.Fatal(err)
  144. }
  145. }
  146. const expiry = int64(1917043200000) // 2030-10-01 00:00:00 UTC
  147. for i, day := range tt.days {
  148. tag := fmt.Sprintf("client%d", i)
  149. seedSubInbound(t, "mixed", tag, 4932+i, i, `{"network":"tcp","security":"none"}`)
  150. clientExpiry := expiry
  151. if i == 1 && tt.different {
  152. clientExpiry += 31 * 24 * time.Hour.Milliseconds()
  153. }
  154. if err := db.Model(&model.ClientRecord{}).Where("email = ?", tag+"@e").Updates(map[string]any{
  155. "expiry_time": clientExpiry, "reset_day": day,
  156. }).Error; err != nil {
  157. t.Fatal(err)
  158. }
  159. if err := db.Create(&xray.ClientTraffic{Email: tag + "@e", Enable: true}).Error; err != nil {
  160. t.Fatal(err)
  161. }
  162. }
  163. router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
  164. for _, path := range []string{"/sub/mixed", "/json/mixed", "/clash/mixed"} {
  165. resp := httptest.NewRecorder()
  166. router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil))
  167. if resp.Code != http.StatusOK || !strings.HasSuffix(resp.Header().Get("Subscription-Userinfo"), fmt.Sprintf("expire=%d", tt.wantExpiry)) {
  168. t.Fatalf("GET %s: status=%d userinfo=%q, want expire=%d", path, resp.Code, resp.Header().Get("Subscription-Userinfo"), tt.wantExpiry)
  169. }
  170. }
  171. })
  172. }
  173. }
  174. func TestSubscriptionCalendarExpireInclusiveSettingRoundTrip(t *testing.T) {
  175. seedSubDB(t)
  176. db := database.GetDB()
  177. seedSubInbound(t, "toggle", "monthly", 4935, 1, `{"network":"tcp","security":"none"}`)
  178. const expiry = int64(1917043200000)
  179. if err := db.Model(&model.ClientRecord{}).Where("email = ?", "monthly@e").Updates(map[string]any{
  180. "expiry_time": expiry, "reset_day": 1,
  181. }).Error; err != nil {
  182. t.Fatal(err)
  183. }
  184. if err := db.Create(&xray.ClientTraffic{Email: "monthly@e", Enable: true}).Error; err != nil {
  185. t.Fatal(err)
  186. }
  187. settings := &service.SettingService{}
  188. all, err := settings.GetAllSetting()
  189. if err != nil {
  190. t.Fatal(err)
  191. }
  192. if all.SubCalendarExpireInclusive {
  193. t.Fatal("inclusive presentation must default off")
  194. }
  195. all.TimeLocation = "UTC"
  196. router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
  197. for _, enabled := range []bool{false, true, false} {
  198. all.SubCalendarExpireInclusive = enabled
  199. if err := settings.UpdateAllSetting(all, service.SecretClears{}); err != nil {
  200. t.Fatal(err)
  201. }
  202. stored, err := settings.GetAllSetting()
  203. if err != nil || stored.SubCalendarExpireInclusive != enabled {
  204. t.Fatalf("setting round trip: enabled=%v stored=%+v err=%v", enabled, stored, err)
  205. }
  206. resp := httptest.NewRecorder()
  207. router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/toggle", nil))
  208. want := expiry / 1000
  209. if enabled {
  210. want--
  211. }
  212. if got := resp.Header().Get("Subscription-Userinfo"); resp.Code != http.StatusOK || got != fmt.Sprintf("upload=0; download=0; total=0; expire=%d", want) {
  213. t.Fatalf("enabled=%v: status=%d userinfo=%q, want expire=%d", enabled, resp.Code, got, want)
  214. }
  215. }
  216. }
  217. func TestSubscriptionCalendarExpireInclusiveFirstUseDuration(t *testing.T) {
  218. seedSubDB(t)
  219. db := database.GetDB()
  220. const duration = -int64(24 * time.Hour / time.Millisecond)
  221. if err := db.Create(&model.ClientRecord{Email: "first-use@e", ExpiryTime: duration, ResetDay: 1}).Error; err != nil {
  222. t.Fatal(err)
  223. }
  224. if err := db.Create(&xray.ClientTraffic{Email: "first-use@e", ExpiryTime: duration, ResetDay: 1}).Error; err != nil {
  225. t.Fatal(err)
  226. }
  227. before := time.Now().UnixMilli()
  228. agg, _ := (&SubService{}).AggregateTrafficByEmails([]string{"first-use@e"})
  229. after := time.Now().UnixMilli()
  230. if agg.ResetDay != 0 || agg.ExpiryTime < before-duration || agg.ExpiryTime > after-duration {
  231. t.Fatalf("first-use duration must not become a canonical calendar cutoff: %+v", agg)
  232. }
  233. }