4
0

aim_handler_test.go 16 KB

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