ratelimit_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. package handlers
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "log/slog"
  7. "net/http"
  8. "net/http/httptest"
  9. "testing"
  10. "time"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. "github.com/mk6i/open-oscar-server/server/webapi/types"
  14. "github.com/mk6i/open-oscar-server/state"
  15. "github.com/mk6i/open-oscar-server/wire"
  16. )
  17. // tightRateLimitClasses returns rate classes scaled down so tests run fast.
  18. //
  19. // OSCAR's moving average tracks the interval between requests in milliseconds,
  20. // seeded at MaxLevel, and each back-to-back request halves it at WindowSize 2.
  21. // So from 200 the sequence is 100 (clear), 50 (limited), 25, 12, 6 — the second
  22. // request trips the limit, and none of the first five fall below the disconnect
  23. // threshold. Recovering past ClearLevel takes a ~150ms pause rather than the
  24. // several seconds the production classes would need.
  25. func tightRateLimitClasses() wire.RateLimitClasses {
  26. var classes [5]wire.RateClass
  27. for i := range classes {
  28. classes[i] = wire.RateClass{
  29. ID: wire.RateLimitClassID(i + 1),
  30. WindowSize: 2,
  31. ClearLevel: 100,
  32. AlertLevel: 80,
  33. LimitLevel: 70,
  34. DisconnectLevel: 2,
  35. MaxLevel: 200,
  36. }
  37. }
  38. return wire.NewRateLimitClasses(classes)
  39. }
  40. // newTestOSCARInstance builds an OSCAR session with rate limit state
  41. // initialized, mirroring what RegisterBOSSession does at startSession time.
  42. func newTestOSCARInstance(t *testing.T, classes wire.RateLimitClasses) *state.SessionInstance {
  43. t.Helper()
  44. instance := state.NewSession().AddInstance()
  45. instance.Session().SetIdentScreenName(state.NewIdentScreenName("me"))
  46. instance.Session().SetDisplayScreenName("me")
  47. instance.Session().SetRateClasses(time.Now(), classes)
  48. return instance
  49. }
  50. // newTestWebAPISessionOn builds a WebAPI session over an existing OSCAR
  51. // instance. Two of them model two browser tabs signed in as the same account:
  52. // each tab holds its own aimsid and its own WebAPISession, but the account has
  53. // one OSCAR session and therefore one set of rate limit states.
  54. func newTestWebAPISessionOn(aimsid string, instance *state.SessionInstance) *state.WebAPISession {
  55. return &state.WebAPISession{
  56. AimSID: aimsid,
  57. ScreenName: "me",
  58. OSCARSession: instance,
  59. EventQueue: types.NewEventQueue(10),
  60. }
  61. }
  62. // newTestWebAPISession builds a WebAPI session backed by a real OSCAR session
  63. // with rate limit state initialized.
  64. func newTestWebAPISession(t *testing.T, classes wire.RateLimitClasses) *state.WebAPISession {
  65. t.Helper()
  66. return newTestWebAPISessionOn("aimsid-1", newTestOSCARInstance(t, classes))
  67. }
  68. func newTestRateLimitMiddleware() *RateLimitMiddleware {
  69. return NewRateLimitMiddleware(wire.DefaultSNACRateLimits(), slog.New(slog.DiscardHandler))
  70. }
  71. // rateLimitEventStatuses returns the status string of every rateLimit event
  72. // queued on the session, in order.
  73. func rateLimitEventStatuses(t *testing.T, session *state.WebAPISession) []string {
  74. t.Helper()
  75. var statuses []string
  76. for _, event := range session.EventQueue.GetAllEvents() {
  77. if event.Type != types.EventTypeRateLimit {
  78. continue
  79. }
  80. payload, ok := event.Data.(types.RateLimitEvent)
  81. if !assert.True(t, ok, "rateLimit event carried %T", event.Data) {
  82. continue
  83. }
  84. if assert.Len(t, payload.Classes, 1) {
  85. statuses = append(statuses, payload.Classes[0].Status)
  86. }
  87. }
  88. return statuses
  89. }
  90. // assertRateLimited checks that a response is the Web API's rate limit
  91. // rejection: HTTP 200 at the transport level so the client parses the body,
  92. // with envelope code 430 carrying the rejection inside. wantRetryAfter is the
  93. // expected Retry-After header, "" for a rejection that carries none.
  94. func assertRateLimited(t *testing.T, rec *httptest.ResponseRecorder, wantRetryAfter string) {
  95. t.Helper()
  96. assert.Equal(t, http.StatusOK, rec.Code)
  97. assert.Equal(t, wantRetryAfter, rec.Header().Get("Retry-After"))
  98. var envelope struct {
  99. Response struct {
  100. StatusCode int `json:"statusCode"`
  101. StatusText string `json:"statusText"`
  102. } `json:"response"`
  103. }
  104. assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
  105. assert.Equal(t, rateLimitStatusCode, envelope.Response.StatusCode)
  106. assert.Equal(t, "rate limit exceeded", envelope.Response.StatusText)
  107. }
  108. func TestRateLimitMiddleware_OSCAR(t *testing.T) {
  109. tests := []struct {
  110. name string
  111. // foodGroup/subGroup passed to the middleware
  112. foodGroup uint16
  113. subGroup uint16
  114. // requests is how many times the wrapped handler is invoked
  115. requests int
  116. // wantCalls is how many of those requests reach the handler
  117. wantCalls int
  118. // wantRetryAfter is the Retry-After header on the final rejection, "" for
  119. // a rejection that carries none
  120. wantRetryAfter string
  121. }{
  122. {
  123. name: "first request is allowed",
  124. foodGroup: wire.ICBM,
  125. subGroup: wire.ICBMChannelMsgToHost,
  126. requests: 1,
  127. wantCalls: 1,
  128. },
  129. {
  130. name: "a burst trips the limit",
  131. foodGroup: wire.ICBM,
  132. subGroup: wire.ICBMChannelMsgToHost,
  133. requests: 5,
  134. wantCalls: 1,
  135. // retryAfterFor rounds the sub-second wait these tight classes need
  136. // up to the minRetryAfter floor.
  137. wantRetryAfter: "1",
  138. },
  139. {
  140. name: "sustained abuse escalates to disconnect",
  141. foodGroup: wire.ICBM,
  142. subGroup: wire.ICBMChannelMsgToHost,
  143. // the 7th request drives the average below DisconnectLevel
  144. requests: 7,
  145. wantCalls: 1,
  146. // a disconnected session has no aimsid left to retry with
  147. wantRetryAfter: "",
  148. },
  149. {
  150. // A non-IM class is enforced the same way. The middleware only ever
  151. // rejects; notifying the client is the per-account monitor's job, and
  152. // it surfaces only the IM class to the alert.
  153. name: "a non-IM class is still enforced",
  154. foodGroup: wire.Feedbag,
  155. subGroup: wire.FeedbagInsertItem,
  156. requests: 5,
  157. wantCalls: 1,
  158. wantRetryAfter: "1",
  159. },
  160. {
  161. name: "unmapped SNAC fails open",
  162. // 0xFFFF is not a food group, so no rate class maps to it
  163. foodGroup: 0xFFFF,
  164. subGroup: 0xFFFF,
  165. requests: 5,
  166. wantCalls: 5,
  167. },
  168. }
  169. for _, tt := range tests {
  170. t.Run(tt.name, func(t *testing.T) {
  171. session := newTestWebAPISession(t, tightRateLimitClasses())
  172. middleware := newTestRateLimitMiddleware()
  173. calls := 0
  174. handler := middleware.OSCAR(tt.foodGroup, tt.subGroup)(
  175. func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {
  176. calls++
  177. w.WriteHeader(http.StatusOK)
  178. })
  179. var last *httptest.ResponseRecorder
  180. for range tt.requests {
  181. last = httptest.NewRecorder()
  182. handler(last, httptest.NewRequest(http.MethodGet, "/im/sendIM", nil), session)
  183. }
  184. assert.Equal(t, tt.wantCalls, calls)
  185. // The middleware enforces the limit but never pushes rate limit
  186. // events; that is the per-account monitor's responsibility.
  187. assert.Empty(t, rateLimitEventStatuses(t, session))
  188. if tt.wantCalls < tt.requests {
  189. assertRateLimited(t, last, tt.wantRetryAfter)
  190. } else {
  191. assert.Equal(t, http.StatusOK, last.Code)
  192. }
  193. })
  194. }
  195. }
  196. // The monitor broadcasts transitions, not current state, so without a seed a
  197. // session signing on mid-limit shows no banner while its sends are rejected — and
  198. // the client's alert is sticky, so the eventual "clear" has nothing to dismiss.
  199. func TestSeedRateLimitAlert(t *testing.T) {
  200. imClass, ok := wire.DefaultSNACRateLimits().RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
  201. require.True(t, ok)
  202. // limitedSession returns a session on an account already in the limited state.
  203. limitedSession := func(t *testing.T) *state.WebAPISession {
  204. t.Helper()
  205. session := newTestWebAPISession(t, tightRateLimitClasses())
  206. sess := session.OSCARSession.Session()
  207. for i := 0; sess.RateLimitStates()[imClass-1].CurrentStatus != wire.RateLimitStatusLimited; i++ {
  208. require.Less(t, i, 100, "class never reached the limited state")
  209. sess.EvaluateRateLimit(time.Now(), imClass)
  210. }
  211. return session
  212. }
  213. t.Run("a session starting on a limited account is told", func(t *testing.T) {
  214. session := limitedSession(t)
  215. seedRateLimitAlert(session, imClass)
  216. assert.Equal(t, []string{"limit"}, rateLimitEventStatuses(t, session))
  217. })
  218. t.Run("a session starting on a clear account is told nothing", func(t *testing.T) {
  219. session := newTestWebAPISession(t, tightRateLimitClasses())
  220. seedRateLimitAlert(session, imClass)
  221. assert.Empty(t, rateLimitEventStatuses(t, session))
  222. })
  223. t.Run("a zero class id disables the alert", func(t *testing.T) {
  224. session := limitedSession(t)
  225. seedRateLimitAlert(session, 0)
  226. assert.Empty(t, rateLimitEventStatuses(t, session))
  227. })
  228. }
  229. // Retry-After must name a wait that actually clears the limit. A rejected
  230. // request is still charged, so a client retrying on the advertised interval
  231. // drives the moving average toward that interval: a hint below the class's
  232. // ClearLevel holds the average under the bar and the client stays limited
  233. // forever.
  234. func TestRetryAfterFor_clearsTheLimit(t *testing.T) {
  235. classes := wire.DefaultRateLimitClasses()
  236. for _, class := range classes.All() {
  237. t.Run(fmt.Sprintf("class %d", class.ID), func(t *testing.T) {
  238. sess := state.NewSession()
  239. sess.AddInstance()
  240. now := time.Now()
  241. sess.SetRateClasses(now, classes)
  242. // Burst until the class trips.
  243. var status wire.RateLimitStatus
  244. for status != wire.RateLimitStatusLimited {
  245. now = now.Add(100 * time.Millisecond)
  246. status = sess.EvaluateRateLimit(now, class.ID)
  247. require.NotEqual(t, wire.RateLimitStatusDisconnect, status, "burst escalated past limited")
  248. }
  249. // Wait exactly as long as the rejection advertised, then retry.
  250. retryAfter := retryAfterFor(sess.RateLimitStates()[class.ID-1])
  251. now = now.Add(retryAfter)
  252. assert.Equal(t, wire.RateLimitStatusClear, sess.EvaluateRateLimit(now, class.ID),
  253. "honoring a Retry-After of %s did not clear the limit", retryAfter)
  254. })
  255. }
  256. }
  257. // The rejection advertises the wait computed for the class it charged, not a
  258. // flat one.
  259. func TestRateLimitMiddleware_OSCAR_retryAfterMatchesClass(t *testing.T) {
  260. session := newTestWebAPISession(t, wire.DefaultRateLimitClasses())
  261. middleware := newTestRateLimitMiddleware()
  262. handler := middleware.OSCAR(wire.ICBM, wire.ICBMChannelMsgToHost)(
  263. func(w http.ResponseWriter, r *http.Request, s *state.WebAPISession) {})
  264. classID, ok := wire.DefaultSNACRateLimits().RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
  265. require.True(t, ok)
  266. // Back-to-back requests trip the limit; keep going until one is rejected.
  267. var rec *httptest.ResponseRecorder
  268. for range 20 {
  269. rec = httptest.NewRecorder()
  270. handler(rec, httptest.NewRequest(http.MethodGet, "/im/sendIM", nil), session)
  271. if rec.Header().Get("Retry-After") != "" {
  272. break
  273. }
  274. }
  275. want := retryAfterFor(session.OSCARSession.Session().RateLimitStates()[classID-1])
  276. assert.Equal(t, fmt.Sprintf("%d", int(want.Seconds())), rec.Header().Get("Retry-After"))
  277. // The production IM class clears at 5100ms, so the wait is necessarily
  278. // longer than the flat 5s hint this replaced.
  279. assert.Greater(t, want, 5*time.Second)
  280. }
  281. // The rejection is encoded in whatever format the request negotiates, via the
  282. // same SendResponse path a normal handler uses.
  283. func TestRateLimitMiddleware_sendRateLimited(t *testing.T) {
  284. tests := []struct {
  285. name string
  286. // query is appended to the request URL
  287. query string
  288. // wantCode is the transport status
  289. wantCode int
  290. // wantBody is the exact response body
  291. wantBody string
  292. // wantContentType is a substring of the Content-Type header
  293. wantContentType string
  294. }{
  295. {
  296. name: "plain JSON",
  297. query: "",
  298. wantCode: http.StatusOK,
  299. wantBody: `{"response":{"statusCode":430,"statusText":"rate limit exceeded"}}`,
  300. wantContentType: "application/json",
  301. },
  302. {
  303. name: "JSONP callback",
  304. query: "?c=myCallback",
  305. wantCode: http.StatusOK,
  306. wantBody: `myCallback({"response":{"statusCode":430,"statusText":"rate limit exceeded"}});`,
  307. wantContentType: "application/javascript",
  308. },
  309. {
  310. // The client correlates a JSONP reply solely by response.requestId,
  311. // so the request's "r" param must be echoed back or the request hangs.
  312. name: "JSONP callback echoes requestId",
  313. query: "?c=myCallback&r=42",
  314. wantCode: http.StatusOK,
  315. wantBody: `myCallback({"response":{"statusCode":430,"statusText":"rate limit exceeded","requestId":"42"}});`,
  316. wantContentType: "application/javascript",
  317. },
  318. {
  319. // Parens would let the callback name inject script; SendResponse
  320. // rejects the malformed callback rather than reflecting it.
  321. name: "invalid JSONP callback is rejected",
  322. query: "?c=alert(1)",
  323. wantCode: http.StatusBadRequest,
  324. // sendJSONError encodes with json.Encoder, which appends a newline.
  325. wantBody: "{\"response\":{\"statusCode\":400,\"statusText\":\"invalid callback parameter\"}}\n",
  326. wantContentType: "application/json",
  327. },
  328. }
  329. for _, tt := range tests {
  330. t.Run(tt.name, func(t *testing.T) {
  331. rec := httptest.NewRecorder()
  332. newTestRateLimitMiddleware().sendRateLimited(rec, httptest.NewRequest(http.MethodGet, "/im/sendIM"+tt.query, nil), 5*time.Second)
  333. body, err := io.ReadAll(rec.Body)
  334. assert.NoError(t, err)
  335. assert.Equal(t, tt.wantCode, rec.Code)
  336. assert.Contains(t, rec.Header().Get("Content-Type"), tt.wantContentType)
  337. assert.Equal(t, tt.wantBody, string(body))
  338. })
  339. }
  340. }
  341. // A client asking for XML or AMF (via f= or the Accept header) still gets a
  342. // parseable rejection envelope rather than JSON, since the rejection rides the
  343. // same SendResponse path as a normal handler.
  344. func TestRateLimitMiddleware_sendRateLimited_nonJSONFormats(t *testing.T) {
  345. t.Run("f=xml", func(t *testing.T) {
  346. rec := httptest.NewRecorder()
  347. newTestRateLimitMiddleware().sendRateLimited(rec, httptest.NewRequest(http.MethodGet, "/im/sendIM?f=xml", nil), 5*time.Second)
  348. assert.Equal(t, http.StatusOK, rec.Code)
  349. assert.Equal(t, "5", rec.Header().Get("Retry-After"))
  350. assert.Contains(t, rec.Header().Get("Content-Type"), "xml")
  351. body := rec.Body.String()
  352. assert.Contains(t, body, "<statusCode>430</statusCode>")
  353. assert.Contains(t, body, "rate limit exceeded")
  354. })
  355. t.Run("f=amf", func(t *testing.T) {
  356. rec := httptest.NewRecorder()
  357. newTestRateLimitMiddleware().sendRateLimited(rec, httptest.NewRequest(http.MethodGet, "/im/sendIM?f=amf", nil), 5*time.Second)
  358. assert.Equal(t, http.StatusOK, rec.Code)
  359. assert.Equal(t, "5", rec.Header().Get("Retry-After"))
  360. assert.Contains(t, rec.Header().Get("Content-Type"), "amf")
  361. assert.NotEmpty(t, rec.Body.Bytes())
  362. })
  363. t.Run("Accept amf", func(t *testing.T) {
  364. rec := httptest.NewRecorder()
  365. req := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
  366. req.Header.Set("Accept", "application/x-amf")
  367. newTestRateLimitMiddleware().sendRateLimited(rec, req, 5*time.Second)
  368. assert.Equal(t, http.StatusOK, rec.Code)
  369. assert.Contains(t, rec.Header().Get("Content-Type"), "amf")
  370. assert.NotEmpty(t, rec.Body.Bytes())
  371. })
  372. }
  373. func TestRateLimitStatusName(t *testing.T) {
  374. tests := []struct {
  375. name string
  376. status wire.RateLimitStatus
  377. want string
  378. }{
  379. {name: "clear", status: wire.RateLimitStatusClear, want: "clear"},
  380. {name: "alert maps to the client's warn", status: wire.RateLimitStatusAlert, want: "warn"},
  381. {name: "limited", status: wire.RateLimitStatusLimited, want: "limit"},
  382. {name: "disconnect", status: wire.RateLimitStatusDisconnect, want: "disconnect"},
  383. {name: "unknown status has no client equivalent", status: wire.RateLimitStatus(0), want: ""},
  384. }
  385. for _, tt := range tests {
  386. t.Run(tt.name, func(t *testing.T) {
  387. assert.Equal(t, tt.want, rateLimitStatusName(tt.status))
  388. })
  389. }
  390. }