aim_handler_test.go 16 KB

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