helpers_test.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. package webapi
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "testing"
  7. "time"
  8. "github.com/stretchr/testify/assert"
  9. "github.com/stretchr/testify/mock"
  10. "github.com/mk6i/open-oscar-server/state"
  11. "github.com/mk6i/open-oscar-server/wire"
  12. )
  13. // newTestIconSource returns a BuddyIconSource whose users have no buddy icon,
  14. // for tests that are not exercising icons.
  15. func newTestIconSource(t *testing.T) BuddyIconSource {
  16. iconRetriever := newMockBuddyIconRetriever(t)
  17. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
  18. return BuddyIconSource{
  19. IconRetriever: iconRetriever,
  20. BARTService: newMockBARTService(t),
  21. Logger: slog.Default(),
  22. }
  23. }
  24. // tightRateLimitClasses returns rate classes scaled down so tests run fast.
  25. //
  26. // OSCAR's moving average tracks the interval between requests in milliseconds,
  27. // seeded at MaxLevel, and each back-to-back request halves it at WindowSize 2.
  28. // So from 200 the sequence is 100 (clear), 50 (limited), 25, 12, 6 — the second
  29. // request trips the limit, and none of the first five fall below the disconnect
  30. // threshold. Recovering past ClearLevel takes a ~150ms pause rather than the
  31. // several seconds the production classes would need.
  32. func tightRateLimitClasses() wire.RateLimitClasses {
  33. var classes [5]wire.RateClass
  34. for i := range classes {
  35. classes[i] = wire.RateClass{
  36. ID: wire.RateLimitClassID(i + 1),
  37. WindowSize: 2,
  38. ClearLevel: 100,
  39. AlertLevel: 80,
  40. LimitLevel: 70,
  41. DisconnectLevel: 2,
  42. MaxLevel: 200,
  43. }
  44. }
  45. return wire.NewRateLimitClasses(classes)
  46. }
  47. // newTestOSCARInstance builds an OSCAR session with rate limit state
  48. // initialized, mirroring what RegisterBOSSession does at startSession time.
  49. func newTestOSCARInstance(t *testing.T, classes wire.RateLimitClasses) *state.SessionInstance {
  50. t.Helper()
  51. instance := state.NewSession().AddInstance()
  52. instance.Session().SetIdentScreenName(state.NewIdentScreenName("me"))
  53. instance.Session().SetDisplayScreenName("me")
  54. instance.Session().SetRateClasses(time.Now(), classes)
  55. return instance
  56. }
  57. // newTestWebAPISessionOn builds a WebAPI session over an existing OSCAR
  58. // instance. Two of them model two browser tabs signed in as the same account:
  59. // each tab holds its own aimsid and its own Session, but the account has
  60. // one OSCAR session and therefore one set of rate limit states.
  61. func newTestWebAPISessionOn(aimsid string, instance *state.SessionInstance) *Session {
  62. return &Session{
  63. AimSID: aimsid,
  64. ScreenName: "me",
  65. OSCARSession: instance,
  66. EventQueue: NewEventQueue(10),
  67. FeedbagLoader: emptyFeedbagLoader,
  68. }
  69. }
  70. // emptyFeedbagLoader stands in for the loader startSession wires, for sessions
  71. // whose test does not care what is on the roster.
  72. func emptyFeedbagLoader(context.Context) ([]wire.FeedbagItem, error) {
  73. return nil, nil
  74. }
  75. // newTestWebAPISession builds a WebAPI session backed by a real OSCAR session
  76. // with rate limit state initialized.
  77. func newTestWebAPISession(t *testing.T, classes wire.RateLimitClasses) *Session {
  78. t.Helper()
  79. return newTestWebAPISessionOn("aimsid-1", newTestOSCARInstance(t, classes))
  80. }
  81. // rateLimitEventStatuses returns the status string of every rateLimit event
  82. // queued on the session, in order.
  83. func rateLimitEventStatuses(t *testing.T, session *Session) []string {
  84. t.Helper()
  85. var statuses []string
  86. for _, event := range session.EventQueue.GetAllEvents() {
  87. if event.Type != EventTypeRateLimit {
  88. continue
  89. }
  90. payload, ok := event.Data.(RateLimitEvent)
  91. if !assert.True(t, ok, "rateLimit event carried %T", event.Data) {
  92. continue
  93. }
  94. if assert.Len(t, payload.Classes, 1) {
  95. statuses = append(statuses, payload.Classes[0].Status)
  96. }
  97. }
  98. return statuses
  99. }
  100. // buddyArrives feeds a BuddyArrived through the session's SNAC handler, which is
  101. // how its presence view learns that a buddy is online.
  102. func buddyArrives(sess *Session, info wire.TLVUserInfo) {
  103. sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{TLVUserInfo: info}})
  104. }
  105. // buddyDeparts feeds a BuddyDeparted through the session's SNAC handler.
  106. func buddyDeparts(sess *Session, screenName string) {
  107. sess.handleBuddyDeparted(wire.SNACMessage{Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
  108. TLVUserInfo: wire.TLVUserInfo{ScreenName: screenName},
  109. }})
  110. }
  111. // onlineBuddy is the user info an ordinary online buddy arrives with.
  112. func onlineBuddy(screenName string) wire.TLVUserInfo {
  113. return wire.TLVUserInfo{ScreenName: screenName}
  114. }
  115. // bartBuddy is the user info a buddy arrives with when they advertise BART
  116. // items, which carry the icon hash and the status message.
  117. func bartBuddy(screenName string, ids ...wire.BARTID) wire.TLVUserInfo {
  118. info := wire.TLVUserInfo{ScreenName: screenName}
  119. info.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, ids))
  120. return info
  121. }
  122. // aliasFeedbagLoader returns a FeedbagLoader serving one buddy item per alias.
  123. func aliasFeedbagLoader(aliases map[string]string) func(context.Context) ([]wire.FeedbagItem, error) {
  124. return func(context.Context) ([]wire.FeedbagItem, error) {
  125. return aliasFeedbagItems(aliases), nil
  126. }
  127. }
  128. // aliasFeedbagItems builds the buddy rows carrying the given aliases.
  129. func aliasFeedbagItems(aliases map[string]string) []wire.FeedbagItem {
  130. items := make([]wire.FeedbagItem, 0, len(aliases))
  131. var itemID uint16
  132. for name, alias := range aliases {
  133. itemID++
  134. item := wire.FeedbagItem{ItemID: itemID, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: name}
  135. item.TLVLBlock = wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, alias)}}
  136. items = append(items, item)
  137. }
  138. return items
  139. }
  140. // feedbagServiceLoader returns a FeedbagLoader backed by a FeedbagService, as
  141. // startSession wires it.
  142. func feedbagServiceLoader(fs FeedbagService, instance *state.SessionInstance) func(context.Context) ([]wire.FeedbagItem, error) {
  143. return func(ctx context.Context) ([]wire.FeedbagItem, error) {
  144. snac, err := fs.Query(ctx, instance, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery})
  145. if err != nil {
  146. return nil, err
  147. }
  148. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  149. if !ok {
  150. return nil, fmt.Errorf("unexpected feedbag reply type")
  151. }
  152. return reply.Items, nil
  153. }
  154. }