aim_handler_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. package webapi
  2. import (
  3. "encoding/base64"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "log/slog"
  8. "net/http"
  9. "net/http/httptest"
  10. "net/url"
  11. "testing"
  12. "time"
  13. "github.com/stretchr/testify/assert"
  14. "github.com/stretchr/testify/mock"
  15. "github.com/stretchr/testify/require"
  16. "github.com/mk6i/open-oscar-server/config"
  17. "github.com/mk6i/open-oscar-server/state"
  18. "github.com/mk6i/open-oscar-server/wire"
  19. )
  20. func TestBuildMyInfo_UserType(t *testing.T) {
  21. tests := []struct {
  22. name string
  23. screenName string
  24. wantType string
  25. }{
  26. {"aim screen name", "mikekelly", "aim"},
  27. {"icq uin", "123456789", "icq"},
  28. }
  29. for _, tt := range tests {
  30. t.Run(tt.name, func(t *testing.T) {
  31. mi := buildMyInfo(state.DisplayScreenName(tt.screenName), "online", "", "")
  32. assert.Equal(t, tt.wantType, mi.UserType)
  33. })
  34. }
  35. }
  36. func TestBuildMyInfo_BuddyIcon(t *testing.T) {
  37. t.Run("included when set", func(t *testing.T) {
  38. mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "http://x/icon", "")
  39. assert.Equal(t, "http://x/icon", mi.BuddyIcon)
  40. })
  41. t.Run("omitted when empty so the client merge preserves the current icon", func(t *testing.T) {
  42. mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "", "")
  43. assert.Empty(t, mi.BuddyIcon)
  44. // omitempty is what actually keeps it out of the payload.
  45. body, err := json.Marshal(mi)
  46. assert.NoError(t, err)
  47. assert.NotContains(t, string(body), "buddyIcon")
  48. })
  49. }
  50. func TestAimHandler_AddTempBuddy(t *testing.T) {
  51. tests := []struct {
  52. name string
  53. queryParams map[string][]string
  54. mockSetup func(*mockBuddyService, *state.SessionInstance)
  55. expectedStatusCode int
  56. expectedResponse string
  57. }{
  58. {
  59. name: "Success_SingleBuddy",
  60. queryParams: map[string][]string{
  61. "aimsid": {"aimsid-1"},
  62. "t": {"buddy1"},
  63. },
  64. mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
  65. svc.EXPECT().
  66. AddTempBuddies(mock.Anything, instance, wire.SNACFrame{}, tempBuddiesSNAC("buddy1")).
  67. Return(nil, nil)
  68. },
  69. expectedStatusCode: http.StatusOK,
  70. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  71. },
  72. {
  73. name: "Success_MultipleBuddies",
  74. queryParams: map[string][]string{
  75. "aimsid": {"aimsid-1"},
  76. "t": {"buddy1", "buddy2", "buddy3"},
  77. },
  78. mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
  79. svc.EXPECT().
  80. AddTempBuddies(mock.Anything, instance, mock.Anything, tempBuddiesSNAC("buddy1", "buddy2", "buddy3")).
  81. Return(nil, nil)
  82. },
  83. expectedStatusCode: http.StatusOK,
  84. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  85. },
  86. {
  87. name: "Success_WhitespaceTrimmed",
  88. queryParams: map[string][]string{
  89. "aimsid": {"aimsid-1"},
  90. "t": {" buddy1 ", "", "buddy2 "},
  91. },
  92. mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
  93. svc.EXPECT().
  94. AddTempBuddies(mock.Anything, instance, mock.Anything, tempBuddiesSNAC("buddy1", "buddy2")).
  95. Return(nil, nil)
  96. },
  97. expectedStatusCode: http.StatusOK,
  98. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  99. },
  100. {
  101. name: "Success_RejectionIsNotReportedToTheClient",
  102. queryParams: map[string][]string{
  103. "aimsid": {"aimsid-1"},
  104. "t": {"buddy1", "100000"},
  105. },
  106. mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
  107. svc.EXPECT().
  108. AddTempBuddies(mock.Anything, instance, mock.Anything, tempBuddiesSNAC("buddy1", "100000")).
  109. Return(&wire.SNACMessage{
  110. Frame: wire.SNACFrame{
  111. FoodGroup: wire.Buddy,
  112. SubGroup: wire.BuddyRejectNotification,
  113. },
  114. Body: wire.SNAC_0x03_0x0A_BuddyRejectNotification{
  115. Buddies: []struct {
  116. ScreenName string `oscar:"len_prefix=uint8"`
  117. }{
  118. {ScreenName: "100000"},
  119. },
  120. },
  121. }, nil)
  122. },
  123. expectedStatusCode: http.StatusOK,
  124. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  125. },
  126. {
  127. name: "Success_CommaSeparatedList",
  128. queryParams: map[string][]string{
  129. "aimsid": {"aimsid-1"},
  130. "t": {"buddy1,buddy2", "buddy3"},
  131. },
  132. mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
  133. svc.EXPECT().
  134. AddTempBuddies(mock.Anything, instance, mock.Anything, tempBuddiesSNAC("buddy1", "buddy2", "buddy3")).
  135. Return(nil, nil)
  136. },
  137. expectedStatusCode: http.StatusOK,
  138. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  139. },
  140. {
  141. name: "Error_MissingBuddyNames",
  142. queryParams: map[string][]string{
  143. "aimsid": {"aimsid-1"},
  144. },
  145. expectedStatusCode: http.StatusBadRequest,
  146. expectedResponse: `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)","data":{}}}`,
  147. },
  148. {
  149. name: "Error_ServiceFailure",
  150. queryParams: map[string][]string{
  151. "aimsid": {"aimsid-1"},
  152. "t": {"buddy1"},
  153. },
  154. mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
  155. svc.EXPECT().
  156. AddTempBuddies(mock.Anything, instance, mock.Anything, mock.Anything).
  157. Return(nil, io.ErrUnexpectedEOF)
  158. },
  159. expectedStatusCode: http.StatusInternalServerError,
  160. expectedResponse: `{"response":{"statusCode":500,"statusText":"unable to add temporary buddies","data":{}}}`,
  161. },
  162. }
  163. for _, tt := range tests {
  164. t.Run(tt.name, func(t *testing.T) {
  165. session := newTestWebAPISession(t, tightRateLimitClasses())
  166. buddyService := newMockBuddyService(t)
  167. if tt.mockSetup != nil {
  168. tt.mockSetup(buddyService, session.OSCARSession)
  169. }
  170. handler := &AimHandler{
  171. BuddyService: buddyService,
  172. Logger: slog.Default(),
  173. }
  174. values := url.Values{}
  175. for key, vals := range tt.queryParams {
  176. for _, val := range vals {
  177. values.Add(key, val)
  178. }
  179. }
  180. req := httptest.NewRequest(http.MethodGet, "/aim/addTempBuddy?"+values.Encode(), nil)
  181. rr := httptest.NewRecorder()
  182. handler.AddTempBuddy(rr, req, session)
  183. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  184. assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
  185. })
  186. }
  187. }
  188. func TestAimHandler_RemoveTempBuddy(t *testing.T) {
  189. tests := []struct {
  190. name string
  191. query string
  192. mockSetup func(*mockBuddyService, *state.SessionInstance)
  193. expectedStatusCode int
  194. expectedResponse string
  195. }{
  196. {
  197. name: "Success",
  198. query: "aimsid=aimsid-1&t=buddy1&t=+buddy2+&t=",
  199. mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
  200. svc.EXPECT().
  201. DelTempBuddies(mock.Anything, instance, wire.SNAC_0x03_0x10_BuddyDelTempBuddies{
  202. Buddies: []struct {
  203. ScreenName string `oscar:"len_prefix=uint8"`
  204. }{
  205. {ScreenName: "buddy1"},
  206. {ScreenName: "buddy2"},
  207. },
  208. }).
  209. Return(nil)
  210. },
  211. expectedStatusCode: http.StatusOK,
  212. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  213. },
  214. {
  215. name: "Error_MissingBuddyNames",
  216. query: "aimsid=aimsid-1",
  217. expectedStatusCode: http.StatusBadRequest,
  218. expectedResponse: `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)","data":{}}}`,
  219. },
  220. {
  221. name: "Error_ServiceFailure",
  222. query: "aimsid=aimsid-1&t=buddy1",
  223. mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
  224. svc.EXPECT().
  225. DelTempBuddies(mock.Anything, instance, mock.Anything).
  226. Return(io.ErrUnexpectedEOF)
  227. },
  228. expectedStatusCode: http.StatusInternalServerError,
  229. expectedResponse: `{"response":{"statusCode":500,"statusText":"unable to remove temporary buddies","data":{}}}`,
  230. },
  231. }
  232. for _, tt := range tests {
  233. t.Run(tt.name, func(t *testing.T) {
  234. session := newTestWebAPISession(t, tightRateLimitClasses())
  235. buddyService := newMockBuddyService(t)
  236. if tt.mockSetup != nil {
  237. tt.mockSetup(buddyService, session.OSCARSession)
  238. }
  239. handler := &AimHandler{
  240. BuddyService: buddyService,
  241. Logger: slog.Default(),
  242. }
  243. req := httptest.NewRequest(http.MethodGet, "/aim/removeTempBuddy?"+tt.query, nil)
  244. rr := httptest.NewRecorder()
  245. handler.RemoveTempBuddy(rr, req, session)
  246. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  247. assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
  248. })
  249. }
  250. }
  251. func TestAimHandler_AddTempBuddy_RejectsOversizedList(t *testing.T) {
  252. session := newTestWebAPISession(t, tightRateLimitClasses())
  253. names := make([]string, 0, maxTempBuddies+1)
  254. for i := range cap(names) {
  255. names = append(names, fmt.Sprintf("buddy%d", i))
  256. }
  257. // No EXPECT: an oversized list must be rejected before it reaches the service.
  258. handler := &AimHandler{
  259. BuddyService: newMockBuddyService(t),
  260. Logger: slog.Default(),
  261. }
  262. values := url.Values{"aimsid": {"aimsid-1"}, "t": names}
  263. req := httptest.NewRequest(http.MethodGet, "/aim/addTempBuddy?"+values.Encode(), nil)
  264. rr := httptest.NewRecorder()
  265. handler.AddTempBuddy(rr, req, session)
  266. assert.Equal(t, http.StatusBadRequest, rr.Code)
  267. assert.JSONEq(t, `{"response":{"statusCode":400,"statusText":"too many buddy names (max 160)","data":{}}}`, rr.Body.String())
  268. }
  269. // tempBuddiesSNAC builds the add-temp-buddies SNAC the handler is expected to
  270. // hand the buddy service.
  271. func tempBuddiesSNAC(screenNames ...string) wire.SNAC_0x03_0x0F_BuddyAddTempBuddies {
  272. snac := wire.SNAC_0x03_0x0F_BuddyAddTempBuddies{}
  273. for _, screenName := range screenNames {
  274. snac.Buddies = append(snac.Buddies, struct {
  275. ScreenName string `oscar:"len_prefix=uint8"`
  276. }{ScreenName: screenName})
  277. }
  278. return snac
  279. }
  280. // testListener is a listener group whose SSL half is present only when the
  281. // test asks for it.
  282. func testListener(sslAvailable bool) config.ListenerGroup {
  283. g := config.ListenerGroup{
  284. Name: "local",
  285. BOSListenAddress: "0.0.0.0:5190",
  286. BOSAdvertisedHostPlain: "bos.example.com:5190",
  287. }
  288. if sslAvailable {
  289. g.BOSListenAddressSSL = "0.0.0.0:5191"
  290. g.BOSAdvertisedHostSSL = "ssl.example.com:5193"
  291. }
  292. return g
  293. }
  294. // bridgeRequest builds a startOSCARSession request.
  295. func bridgeRequest(query string) *http.Request {
  296. return httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?"+query, nil)
  297. }
  298. // bridgeData is the data object of a successful startOSCARSession response.
  299. type bridgeData struct {
  300. Response struct {
  301. StatusCode int `json:"statusCode"`
  302. Data struct {
  303. Host string `json:"host"`
  304. Port int `json:"port"`
  305. Cookie string `json:"cookie"`
  306. TLSCertName string `json:"tlsCertName"`
  307. } `json:"data"`
  308. } `json:"response"`
  309. }
  310. func TestAimHandler_StartOSCARSession(t *testing.T) {
  311. validToken := base64.URLEncoding.EncodeToString(signedCookieFor("testuser"))
  312. tests := []struct {
  313. name string
  314. query string
  315. sslAvailable bool
  316. expectedCode int
  317. checkBody func(t *testing.T, body string)
  318. }{
  319. {
  320. // No tlsCertName, which is how the client reads "connect in the clear".
  321. name: "Success_Plaintext",
  322. query: "a=" + validToken,
  323. expectedCode: http.StatusOK,
  324. checkBody: func(t *testing.T, body string) {
  325. got := decodeBridgeData(t, body)
  326. assert.Equal(t, 200, got.Response.StatusCode)
  327. assert.Equal(t, "bos.example.com", got.Response.Data.Host)
  328. assert.Equal(t, 5190, got.Response.Data.Port)
  329. assert.Empty(t, got.Response.Data.TLSCertName)
  330. },
  331. },
  332. {
  333. name: "Success_TLS",
  334. query: "a=" + validToken + "&useTLS=1",
  335. sslAvailable: true,
  336. expectedCode: http.StatusOK,
  337. checkBody: func(t *testing.T, body string) {
  338. got := decodeBridgeData(t, body)
  339. assert.Equal(t, "ssl.example.com", got.Response.Data.Host)
  340. assert.Equal(t, 5193, got.Response.Data.Port)
  341. // The certificate is issued to the host the client is sent to.
  342. assert.Equal(t, "ssl.example.com", got.Response.Data.TLSCertName)
  343. },
  344. },
  345. {
  346. // Encryption the server cannot provide degrades to a plaintext host
  347. // rather than failing the handoff.
  348. name: "TLSRequestedButUnavailable_DegradesToPlaintext",
  349. query: "a=" + validToken + "&useTLS=true",
  350. sslAvailable: false,
  351. expectedCode: http.StatusOK,
  352. checkBody: func(t *testing.T, body string) {
  353. got := decodeBridgeData(t, body)
  354. assert.Equal(t, "bos.example.com", got.Response.Data.Host)
  355. assert.Empty(t, got.Response.Data.TLSCertName)
  356. },
  357. },
  358. {
  359. name: "Error_MissingToken",
  360. query: "",
  361. expectedCode: http.StatusUnauthorized,
  362. checkBody: func(t *testing.T, body string) {
  363. assert.Contains(t, body, "authentication token required")
  364. },
  365. },
  366. {
  367. name: "Error_TokenNotBase64",
  368. query: "a=not!valid!base64",
  369. expectedCode: http.StatusUnauthorized,
  370. checkBody: func(t *testing.T, body string) {
  371. assert.Contains(t, body, "invalid or expired token")
  372. },
  373. },
  374. {
  375. // A well-formed token the baker refuses to crack: wrong signature or
  376. // past its expiry.
  377. name: "Error_TokenFailsSignatureCheck",
  378. query: "a=" + base64.URLEncoding.EncodeToString([]byte("forged")),
  379. expectedCode: http.StatusUnauthorized,
  380. checkBody: func(t *testing.T, body string) {
  381. assert.Contains(t, body, "invalid or expired token")
  382. },
  383. },
  384. }
  385. for _, tt := range tests {
  386. t.Run(tt.name, func(t *testing.T) {
  387. handler := &AimHandler{
  388. AuthService: &testAuthService{crackCookie: crackSignedCookie},
  389. BOSListener: testListener(tt.sslAvailable),
  390. Logger: slog.Default(),
  391. }
  392. rr := httptest.NewRecorder()
  393. handler.StartOSCARSession(rr, bridgeRequest(tt.query))
  394. assert.Equal(t, tt.expectedCode, rr.Code)
  395. tt.checkBody(t, rr.Body.String())
  396. })
  397. }
  398. }
  399. // The token arrives URL-safe, the way clientLogin minted it, and goes back out in
  400. // standard base64, the alphabet the client decodes the sign-on cookie with. The
  401. // cookie bytes here encode differently under each.
  402. func TestAimHandler_StartOSCARSession_ReencodesCookie(t *testing.T) {
  403. rawCookie := []byte{0xff, 0xef, 0xbe}
  404. urlSafe := base64.URLEncoding.EncodeToString(rawCookie)
  405. standard := base64.StdEncoding.EncodeToString(rawCookie)
  406. assert.NotEqual(t, urlSafe, standard, "test cookie must distinguish the two alphabets")
  407. var cracked []byte
  408. handler := &AimHandler{
  409. AuthService: &testAuthService{
  410. crackCookie: func(authCookie []byte) (state.ServerCookie, time.Time, error) {
  411. cracked = authCookie
  412. return state.ServerCookie{ScreenName: "testuser"}, time.Now().Add(shortTermTTL), nil
  413. },
  414. },
  415. BOSListener: testListener(false),
  416. Logger: slog.Default(),
  417. }
  418. rr := httptest.NewRecorder()
  419. handler.StartOSCARSession(rr, bridgeRequest("a="+urlSafe))
  420. assert.Equal(t, http.StatusOK, rr.Code)
  421. assert.Equal(t, rawCookie, cracked, "the baker sees the decoded cookie")
  422. assert.Equal(t, standard, decodeBridgeData(t, rr.Body.String()).Response.Data.Cookie)
  423. }
  424. func decodeBridgeData(t *testing.T, body string) bridgeData {
  425. t.Helper()
  426. got := bridgeData{}
  427. assert.NoError(t, json.Unmarshal([]byte(body), &got))
  428. return got
  429. }
  430. // The monitor broadcasts transitions, not current state, so without a seed a
  431. // session signing on mid-limit shows no banner while its sends are rejected — and
  432. // the client's alert is sticky, so the eventual "clear" has nothing to dismiss.
  433. func TestSeedRateLimitAlert(t *testing.T) {
  434. imClass, ok := wire.DefaultSNACRateLimits().RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
  435. require.True(t, ok)
  436. // limitedSession returns a session on an account already in the limited state.
  437. limitedSession := func(t *testing.T) *Session {
  438. t.Helper()
  439. session := newTestWebAPISession(t, tightRateLimitClasses())
  440. sess := session.OSCARSession.Session()
  441. for i := 0; sess.RateLimitStates()[imClass-1].CurrentStatus != wire.RateLimitStatusLimited; i++ {
  442. require.Less(t, i, 100, "class never reached the limited state")
  443. sess.EvaluateRateLimit(time.Now(), imClass)
  444. }
  445. return session
  446. }
  447. t.Run("a session starting on a limited account is told", func(t *testing.T) {
  448. session := limitedSession(t)
  449. seedRateLimitAlert(session, imClass)
  450. assert.Equal(t, []string{"limit"}, rateLimitEventStatuses(t, session))
  451. })
  452. t.Run("a session starting on a clear account is told nothing", func(t *testing.T) {
  453. session := newTestWebAPISession(t, tightRateLimitClasses())
  454. seedRateLimitAlert(session, imClass)
  455. assert.Empty(t, rateLimitEventStatuses(t, session))
  456. })
  457. t.Run("a zero class id disables the alert", func(t *testing.T) {
  458. session := limitedSession(t)
  459. seedRateLimitAlert(session, 0)
  460. assert.Empty(t, rateLimitEventStatuses(t, session))
  461. })
  462. }