1
0

tgbot_invite_test.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. package tgbot
  2. import (
  3. "strings"
  4. "sync"
  5. "testing"
  6. "time"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  9. "github.com/mymmrac/telego"
  10. )
  11. func seedClientRecord(t *testing.T, email, subID string, tgID int64) {
  12. t.Helper()
  13. rec := &model.ClientRecord{Email: email, SubID: subID, TgID: tgID, Enable: true}
  14. if err := database.GetDB().Create(rec).Error; err != nil {
  15. t.Fatalf("seed client %s: %v", email, err)
  16. }
  17. }
  18. // Binding is first-claim-wins: the owner re-tapping is idempotent, and no part of
  19. // a subscription held by someone else is ever reassigned.
  20. func TestResolveInviteToken(t *testing.T) {
  21. tb, _ := newLinksCallbackTgbot(t, ownerMail)
  22. seedClientRecord(t, "free@x", "sub-free", 0)
  23. seedClientRecord(t, "held@x", "sub-held", 5150)
  24. seedClientRecord(t, "shared-a@x", "sub-shared", 0)
  25. seedClientRecord(t, "shared-b@x", "sub-shared", 0)
  26. seedClientRecord(t, "part-mine@x", "sub-part-mine", 7000)
  27. seedClientRecord(t, "part-free@x", "sub-part-mine", 0)
  28. seedClientRecord(t, "part-free2@x", "sub-part-held", 0)
  29. seedClientRecord(t, "part-held@x", "sub-part-held", 9999)
  30. cases := []struct {
  31. name string
  32. token string
  33. from int64
  34. want inviteOutcome
  35. records int
  36. }{
  37. {"unclaimed binds", "sub-free", 7000, inviteBindable, 1},
  38. {"token is trimmed", " sub-free\n", 7000, inviteBindable, 1},
  39. {"owner is idempotent", "sub-held", 5150, inviteAlreadyOwned, 1},
  40. {"someone else's is refused", "sub-held", 7000, inviteTaken, 1},
  41. {"shared subscription binds whole", "sub-shared", 7000, inviteBindable, 2},
  42. {"finishing a partly owned one binds", "sub-part-mine", 7000, inviteBindable, 2},
  43. {"partly held by another is refused", "sub-part-held", 7000, inviteTaken, 2},
  44. {"unknown token", "sub-nope", 7000, inviteInvalid, 0},
  45. {"empty token", "", 7000, inviteInvalid, 0},
  46. {"missing sender", "sub-free", 0, inviteInvalid, 0},
  47. }
  48. for _, c := range cases {
  49. got, records := tb.resolveInviteToken(c.token, c.from)
  50. if got != c.want || len(records) != c.records {
  51. t.Errorf("%s: got (%d, %d records), want (%d, %d records)", c.name, got, len(records), c.want, c.records)
  52. }
  53. }
  54. }
  55. func mustInvitePayload(t *testing.T, subID string) string {
  56. t.Helper()
  57. payload, ok := encodeInvitePayload(subID)
  58. if !ok {
  59. t.Fatalf("encodeInvitePayload(%q) refused", subID)
  60. }
  61. return payload
  62. }
  63. // newInviteTgbot seeds one unbound client whose subId is sub-invite.
  64. func newInviteTgbot(t *testing.T, email string) (*Tgbot, func(string) int) {
  65. t.Helper()
  66. tb, calls := newLinksCallbackTgbot(t, email)
  67. if err := database.GetDB().Model(&model.Inbound{}).Where("1 = 1").
  68. Update("settings", `{"clients":[{"id":"6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f","email":"`+email+`","subId":"sub-invite"}]}`).Error; err != nil {
  69. t.Fatalf("unbind seeded client: %v", err)
  70. }
  71. seedClientRecord(t, email, "sub-invite", 0)
  72. withAdmins(t, 1)
  73. return tb, calls
  74. }
  75. // Regression test: a subId with URL metacharacters was pasted raw into the link,
  76. // so Telegram truncated it; every legal subId that fits must survive the trip.
  77. func TestInvitePayloadRoundTrip(t *testing.T) {
  78. for _, subID := range []string{"a1B2c3D4e5F6g7H8", "team#1", "alice&bob", "x?y=z", "کاربر", strings.Repeat("s", 48)} {
  79. payload, ok := encodeInvitePayload(subID)
  80. if !ok {
  81. t.Errorf("encodeInvitePayload(%q) refused", subID)
  82. continue
  83. }
  84. if strings.Trim(payload, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != "" {
  85. t.Errorf("payload %q for %q has characters Telegram rejects", payload, subID)
  86. }
  87. if got, ok := decodeInvitePayload(payload); !ok || got != subID {
  88. t.Errorf("round trip of %q = (%q, %v)", subID, got, ok)
  89. }
  90. }
  91. if _, ok := encodeInvitePayload(strings.Repeat("s", 49)); ok {
  92. t.Error("a subId past 64 payload characters must be refused, not truncated")
  93. }
  94. for _, payload := range []string{"", "not base64!", " "} {
  95. if _, ok := decodeInvitePayload(payload); ok {
  96. t.Errorf("decodeInvitePayload(%q) accepted", payload)
  97. }
  98. }
  99. }
  100. // Regression test: accounts opening one link at once all read TgID == 0, all bound
  101. // and were all told so, while only the last write held; exactly one may succeed.
  102. func TestConcurrentClaimsBindOnlyOneAccount(t *testing.T) {
  103. tb, _ := newInviteTgbot(t, "raced@x")
  104. payload := mustInvitePayload(t, "sub-invite")
  105. claimants := []int64{8101, 8102, 8103, 8104, 8105, 8106}
  106. outcomes := make([]inviteOutcome, len(claimants))
  107. start := make(chan struct{})
  108. var wg sync.WaitGroup
  109. for i, id := range claimants {
  110. wg.Add(1)
  111. go func() {
  112. defer wg.Done()
  113. <-start
  114. outcomes[i] = tb.claimInvite(id, id, payload)
  115. }()
  116. }
  117. // Hold the inbound write every bind needs, so all claimants resolve before any
  118. // bind lands; otherwise the first bind outruns the rest and hides the race.
  119. hold := database.GetDB().Begin()
  120. if err := hold.Exec("UPDATE inbounds SET remark = remark").Error; err != nil {
  121. t.Fatalf("hold inbound write: %v", err)
  122. }
  123. close(start)
  124. time.Sleep(300 * time.Millisecond)
  125. if err := hold.Commit().Error; err != nil {
  126. t.Fatalf("release inbound write: %v", err)
  127. }
  128. wg.Wait()
  129. told, holders := 0, 0
  130. for i, id := range claimants {
  131. if outcomes[i] == inviteBindable {
  132. told++
  133. }
  134. if tb.levelOf(id) == levelClient {
  135. holders++
  136. }
  137. }
  138. if told != 1 || holders != 1 {
  139. t.Errorf("%d accounts told they bound, %d holding the client; want 1 and 1", told, holders)
  140. }
  141. }
  142. // A stranger opening a valid invite link must come out of it a client.
  143. func TestClaimInvitePromotesStrangerToClient(t *testing.T) {
  144. const email = "invitee@x"
  145. tb, calls := newInviteTgbot(t, email)
  146. const newcomer = int64(8080)
  147. if got := tb.levelOf(newcomer); got != levelStranger {
  148. t.Fatalf("levelOf before claim = %d, want stranger", got)
  149. }
  150. tb.claimInvite(newcomer, newcomer, mustInvitePayload(t, "sub-invite"))
  151. if got := tb.levelOf(newcomer); got != levelClient {
  152. t.Errorf("levelOf after claim = %d, want client", got)
  153. }
  154. if n := calls("sendMessage"); n != 1 {
  155. t.Errorf("sendMessage calls = %d, want 1 confirmation", n)
  156. }
  157. if outcome, _ := tb.resolveInviteToken("sub-invite", 9999); outcome != inviteTaken {
  158. t.Errorf("second claimant outcome = %d, want taken", outcome)
  159. }
  160. }
  161. // Guessing a subId must stay slow: past five attempts an hour an account is
  162. // refused, and admins are told once per window rather than once per attempt.
  163. func TestInviteAttemptLimit(t *testing.T) {
  164. _, calls := newLinksCallbackTgbot(t, ownerMail)
  165. withAdmins(t, 1, 2)
  166. tb := &Tgbot{}
  167. now := time.Unix(1_700_000_000, 0)
  168. origNow, origBy := inviteAttemptsNow, inviteAttemptsBy
  169. inviteAttemptsNow = func() time.Time { return now }
  170. inviteAttemptsBy = map[int64]*inviteAttempts{}
  171. t.Cleanup(func() { inviteAttemptsNow, inviteAttemptsBy = origNow, origBy })
  172. guesser := &telego.User{ID: 6666, FirstName: "<b>x</b>"}
  173. for i := 1; i <= inviteAttemptLimit; i++ {
  174. if !tb.allowInviteAttempt(guesser) {
  175. t.Fatalf("attempt %d refused, want allowed", i)
  176. }
  177. }
  178. for range 3 {
  179. if tb.allowInviteAttempt(guesser) {
  180. t.Fatal("attempt past the limit allowed")
  181. }
  182. }
  183. if n := calls("sendMessage"); n != 2 {
  184. t.Errorf("sendMessage calls = %d, want 2: one notice per admin, once per window", n)
  185. }
  186. if !tb.allowInviteAttempt(&telego.User{ID: 7777}) {
  187. t.Error("another account was refused by the guesser's limit")
  188. }
  189. now = now.Add(inviteAttemptWindow)
  190. if !tb.allowInviteAttempt(guesser) {
  191. t.Error("attempt after the window refused, want allowed")
  192. }
  193. }
  194. func TestTgUserMentionEscapesName(t *testing.T) {
  195. got := tgUserMention(&telego.User{ID: 42, FirstName: "<b>Eve</b>", Username: "eve"})
  196. want := `<a href="tg://user?id=42">&lt;b&gt;Eve&lt;/b&gt;</a> @eve`
  197. if got != want {
  198. t.Errorf("tgUserMention = %q, want %q", got, want)
  199. }
  200. }