presence_handler_test.go 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091
  1. package webapi
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "log/slog"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "testing"
  12. "github.com/stretchr/testify/assert"
  13. "github.com/stretchr/testify/mock"
  14. "github.com/stretchr/testify/require"
  15. "github.com/mk6i/open-oscar-server/state"
  16. "github.com/mk6i/open-oscar-server/wire"
  17. )
  18. // onlineUserInfoReply builds a locate UserInfoReply for an online user,
  19. // optionally marking them idle by the given number of minutes (0 = not idle).
  20. func onlineUserInfoReply(screenName string, idleMinutes uint16) wire.SNACMessage {
  21. info := wire.TLVUserInfo{ScreenName: screenName}
  22. if idleMinutes > 0 {
  23. info.Append(wire.NewTLVBE(wire.OServiceUserInfoIdleTime, idleMinutes))
  24. }
  25. return wire.SNACMessage{
  26. Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: info},
  27. }
  28. }
  29. // screenNameMatcher matches a UserInfoQuery request body by its target screen name.
  30. func screenNameMatcher(screenName string) any {
  31. return mock.MatchedBy(func(b wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool {
  32. return b.ScreenName == screenName
  33. })
  34. }
  35. func TestPresenceHandler_GetPresence(t *testing.T) {
  36. tests := []struct {
  37. name string
  38. queryParams string
  39. setupMocks func(*mockFeedbagService, *mockLocateService)
  40. expectedStatusCode int
  41. checkResponse func(*testing.T, string)
  42. }{
  43. {
  44. name: "Success_BuddyList",
  45. queryParams: "bl=1",
  46. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  47. // Return feedbag with a group and buddy
  48. fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  49. Return(wire.SNACMessage{
  50. Body: wire.SNAC_0x13_0x06_FeedbagReply{
  51. Items: []wire.FeedbagItem{
  52. {ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
  53. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
  54. },
  55. },
  56. }, nil)
  57. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("buddy1")).
  58. Return(onlineUserInfoReply("buddy1", 0), nil)
  59. },
  60. expectedStatusCode: http.StatusOK,
  61. checkResponse: func(t *testing.T, body string) {
  62. assert.Contains(t, body, `"statusCode":200`)
  63. assert.Contains(t, body, `"groups"`)
  64. assert.Contains(t, body, `"Friends"`)
  65. assert.Contains(t, body, `"buddy1"`)
  66. assert.Contains(t, body, `"online"`)
  67. },
  68. },
  69. {
  70. name: "Success_TargetUsers",
  71. queryParams: "t=user1,user2",
  72. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  73. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user1")).
  74. Return(onlineUserInfoReply("user1", 0), nil)
  75. // user2 is idle for 7 minutes.
  76. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user2")).
  77. Return(onlineUserInfoReply("user2", 7), nil)
  78. },
  79. expectedStatusCode: http.StatusOK,
  80. checkResponse: func(t *testing.T, body string) {
  81. assert.Contains(t, body, `"statusCode":200`)
  82. assert.Contains(t, body, `"users"`)
  83. assert.Contains(t, body, `"user1"`)
  84. assert.Contains(t, body, `"user2"`)
  85. assert.Contains(t, body, `"idle"`)
  86. },
  87. },
  88. {
  89. name: "Success_BlockedOrOfflineUser",
  90. queryParams: "t=blockeduser",
  91. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  92. // A blocked or offline user comes back as a locate error.
  93. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("blockeduser")).
  94. Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
  95. },
  96. expectedStatusCode: http.StatusOK,
  97. checkResponse: func(t *testing.T, body string) {
  98. assert.Contains(t, body, `"statusCode":200`)
  99. assert.Contains(t, body, `"blockeduser"`)
  100. assert.Contains(t, body, `"offline"`)
  101. },
  102. },
  103. {
  104. name: "Success_EmptyRequest",
  105. queryParams: "",
  106. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {},
  107. expectedStatusCode: http.StatusOK,
  108. checkResponse: func(t *testing.T, body string) {
  109. assert.Contains(t, body, `"statusCode":200`)
  110. },
  111. },
  112. {
  113. // A full search page: memberDir/search returns 20 profiles and the
  114. // client asks about every one, plus the keyword when it is a UIN.
  115. // This is the largest list a real client sends, and it must be served
  116. // whole — anything but a 200 costs the user the entire page.
  117. name: "Success_FullSearchPage",
  118. queryParams: "t=" + strings.Join(searchPageTargets(21), "&t="),
  119. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  120. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  121. RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
  122. return onlineUserInfoReply(body.ScreenName, 0), nil
  123. }).Times(21)
  124. },
  125. expectedStatusCode: http.StatusOK,
  126. checkResponse: func(t *testing.T, body string) {
  127. assert.Contains(t, body, `"statusCode":200`)
  128. assert.Contains(t, body, `"user0"`)
  129. assert.Contains(t, body, `"user20"`)
  130. },
  131. },
  132. }
  133. for _, tt := range tests {
  134. t.Run(tt.name, func(t *testing.T) {
  135. feedbagService := newMockFeedbagService(t)
  136. locateService := newMockLocateService(t)
  137. oscarInstance := state.NewSession().AddInstance()
  138. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  139. handler := &PresenceHandler{
  140. SessionManager: sessionMgr,
  141. FeedbagService: feedbagService,
  142. LocateService: locateService,
  143. Logger: slog.Default(),
  144. }
  145. tt.setupMocks(feedbagService, locateService)
  146. // Presence payloads carry the viewer's alias, so GetPresence reads the
  147. // feedbag. Registered last so a case's own Query stub takes precedence.
  148. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  149. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  150. reqURL := "/presence/get?aimsid=" + aimsid
  151. if tt.queryParams != "" {
  152. reqURL += "&" + tt.queryParams
  153. }
  154. req, err := http.NewRequest("GET", reqURL, nil)
  155. assert.NoError(t, err)
  156. rr := httptest.NewRecorder()
  157. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  158. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  159. if tt.checkResponse != nil {
  160. responseBody := strings.TrimSpace(rr.Body.String())
  161. tt.checkResponse(t, responseBody)
  162. }
  163. })
  164. }
  165. }
  166. // TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly verifies that
  167. // the icon is published only for an online, non-blocking user, and that an
  168. // offline or blocking user is never even looked up — so neither their icon nor
  169. // its hash leaks to a caller they are invisible to.
  170. func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testing.T) {
  171. ctx := context.Background()
  172. feedbagService := newMockFeedbagService(t)
  173. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  174. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  175. locateService := newMockLocateService(t)
  176. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("onlineuser")).
  177. Return(onlineUserInfoReply("onlineuser", 0), nil)
  178. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("offlineuser")).
  179. Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
  180. iconRetriever := newMockBuddyIconRetriever(t)
  181. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlineuser")).
  182. Return(bartID([]byte{0xab, 0xcd}), nil).Once()
  183. oscarInstance := state.NewSession().AddInstance()
  184. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  185. sess, err := sessionMgr.GetSession(ctx, aimsid)
  186. require.NoError(t, err)
  187. sess.BaseURL = "http://api.example.com"
  188. handler := &PresenceHandler{
  189. SessionManager: sessionMgr,
  190. FeedbagService: feedbagService,
  191. LocateService: locateService,
  192. IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
  193. Logger: slog.Default(),
  194. }
  195. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=onlineuser,offlineuser", nil)
  196. require.NoError(t, err)
  197. rr := httptest.NewRecorder()
  198. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  199. require.Equal(t, http.StatusOK, rr.Code)
  200. var got struct {
  201. Response struct {
  202. Data struct {
  203. Users []struct {
  204. AimID string `json:"aimId"`
  205. State string `json:"state"`
  206. BuddyIcon string `json:"buddyIcon"`
  207. } `json:"users"`
  208. } `json:"data"`
  209. } `json:"response"`
  210. }
  211. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  212. icons := map[string]string{}
  213. states := map[string]string{}
  214. for _, u := range got.Response.Data.Users {
  215. icons[u.AimID] = u.BuddyIcon
  216. states[u.AimID] = u.State
  217. }
  218. assert.Equal(t, "online", states["onlineuser"])
  219. assert.Equal(t,
  220. "http://api.example.com/expressions/get?t=onlineuser&type=buddyIcon&bartId=abcd",
  221. icons["onlineuser"])
  222. assert.Equal(t, "offline", states["offlineuser"])
  223. assert.Empty(t, icons["offlineuser"])
  224. iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("offlineuser"))
  225. }
  226. // TestPresenceHandler_GetPresence_BuddyListGrouping verifies that bl=1 places
  227. // each buddy under its own group using realistic feedbag data, where group rows
  228. // carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
  229. // GroupIDs. This is the shape the OSCAR feedbag actually stores.
  230. func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
  231. feedbagService := newMockFeedbagService(t)
  232. locateService := newMockLocateService(t)
  233. items := []wire.FeedbagItem{
  234. // Root order group: ItemID 0, GroupID 0, empty name — not a real buddy group.
  235. {ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
  236. // Named groups: ItemID 0, distinct nonzero GroupIDs.
  237. {ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  238. {ItemID: 0, GroupID: 20, ClassID: wire.FeedbagClassIdGroup, Name: "Work"},
  239. // Buddies reference their group's GroupID.
  240. {ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
  241. {ItemID: 201, GroupID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
  242. }
  243. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  244. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
  245. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("alice")).
  246. Return(onlineUserInfoReply("alice", 0), nil)
  247. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("bob")).
  248. Return(onlineUserInfoReply("bob", 0), nil)
  249. oscarInstance := state.NewSession().AddInstance()
  250. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  251. handler := &PresenceHandler{
  252. SessionManager: sessionMgr,
  253. FeedbagService: feedbagService,
  254. LocateService: locateService,
  255. Logger: slog.Default(),
  256. }
  257. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
  258. assert.NoError(t, err)
  259. rr := httptest.NewRecorder()
  260. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  261. assert.Equal(t, http.StatusOK, rr.Code)
  262. var parsed struct {
  263. Response struct {
  264. Data struct {
  265. Groups []struct {
  266. Name string `json:"name"`
  267. Buddies []struct {
  268. AimID string `json:"aimId"`
  269. } `json:"buddies"`
  270. } `json:"groups"`
  271. } `json:"data"`
  272. } `json:"response"`
  273. }
  274. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &parsed))
  275. // Build name -> set of buddy aimIds.
  276. byGroup := map[string][]string{}
  277. for _, g := range parsed.Response.Data.Groups {
  278. for _, b := range g.Buddies {
  279. byGroup[g.Name] = append(byGroup[g.Name], b.AimID)
  280. }
  281. }
  282. // Exactly the two named groups appear; the root group is excluded.
  283. assert.Len(t, parsed.Response.Data.Groups, 2)
  284. assert.Equal(t, []string{"alice"}, byGroup["Friends"])
  285. assert.Equal(t, []string{"bob"}, byGroup["Work"])
  286. }
  287. func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
  288. handler := &PresenceHandler{
  289. SessionManager: NewSessionManager(),
  290. Logger: slog.Default(),
  291. }
  292. req, err := http.NewRequest("GET", "/presence/get", nil)
  293. assert.NoError(t, err)
  294. rr := httptest.NewRecorder()
  295. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  296. assert.Equal(t, http.StatusBadRequest, rr.Code)
  297. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  298. }
  299. func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
  300. handler := &PresenceHandler{
  301. SessionManager: NewSessionManager(),
  302. Logger: slog.Default(),
  303. }
  304. req, err := http.NewRequest("GET", "/presence/get?aimsid=nonexistent", nil)
  305. assert.NoError(t, err)
  306. rr := httptest.NewRecorder()
  307. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  308. assert.Equal(t, http.StatusUnauthorized, rr.Code)
  309. assert.Contains(t, rr.Body.String(), "invalid or expired session")
  310. }
  311. func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
  312. handler := &PresenceHandler{
  313. SessionManager: NewSessionManager(),
  314. Logger: slog.Default(),
  315. }
  316. req, err := http.NewRequest("GET", "/presence/setState", nil)
  317. assert.NoError(t, err)
  318. rr := httptest.NewRecorder()
  319. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  320. assert.Equal(t, http.StatusBadRequest, rr.Code)
  321. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  322. }
  323. func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
  324. oscarInstance := state.NewSession().AddInstance()
  325. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  326. handler := &PresenceHandler{
  327. SessionManager: sessionMgr,
  328. Logger: slog.Default(),
  329. }
  330. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=bogus", nil)
  331. assert.NoError(t, err)
  332. rr := httptest.NewRecorder()
  333. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  334. assert.Equal(t, http.StatusBadRequest, rr.Code)
  335. assert.Contains(t, rr.Body.String(), "invalid state parameter")
  336. }
  337. func TestPresenceHandler_SetState_EmitsMyInfoEvent(t *testing.T) {
  338. // The AIM client re-renders its own status badge only from "myInfo" events,
  339. // so setState must queue one on the user's own session for the change to be
  340. // visible in their UI.
  341. oscarInstance := state.NewSession().AddInstance()
  342. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  343. broadcaster := newMockBuddyBroadcaster(t)
  344. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  345. handler := &PresenceHandler{
  346. SessionManager: sessionMgr,
  347. BuddyBroadcaster: broadcaster,
  348. Logger: slog.Default(),
  349. }
  350. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away&awayMsg=brb", nil)
  351. assert.NoError(t, err)
  352. rr := httptest.NewRecorder()
  353. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  354. assert.Equal(t, http.StatusOK, rr.Code)
  355. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  356. assert.NoError(t, err)
  357. myInfo := queuedMyInfo(session)
  358. assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
  359. assert.Equal(t, "away", myInfo.State)
  360. assert.Equal(t, "brb", myInfo.AwayMsg)
  361. assert.Equal(t, "testuser", myInfo.AimID)
  362. }
  363. func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
  364. // The client shallow-merges myInfo onto the shared user object, so aimId must
  365. // be the normalized id while displayId and friendly keep the user's own
  366. // casing and spacing.
  367. oscarInstance := state.NewSession().AddInstance()
  368. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("Mike Kelly", oscarInstance)
  369. broadcaster := newMockBuddyBroadcaster(t)
  370. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  371. handler := &PresenceHandler{
  372. SessionManager: sessionMgr,
  373. BuddyBroadcaster: broadcaster,
  374. Logger: slog.Default(),
  375. }
  376. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away", nil)
  377. assert.NoError(t, err)
  378. rr := httptest.NewRecorder()
  379. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  380. assert.Equal(t, http.StatusOK, rr.Code)
  381. // The setState response body carries the same identity fields.
  382. var resp struct {
  383. Response struct {
  384. Data map[string]any `json:"data"`
  385. } `json:"response"`
  386. }
  387. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  388. assert.Equal(t, "mikekelly", resp.Response.Data["aimId"])
  389. assert.Equal(t, "Mike Kelly", resp.Response.Data["displayId"])
  390. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  391. assert.NoError(t, err)
  392. myInfo := queuedMyInfo(session)
  393. require.NotNil(t, myInfo, "expected a myInfo event to be queued")
  394. assert.Equal(t, "mikekelly", myInfo.AimID)
  395. assert.Equal(t, "Mike Kelly", myInfo.DisplayID)
  396. assert.Equal(t, "Mike Kelly", myInfo.Friendly)
  397. }
  398. func TestPresenceHandler_Icon(t *testing.T) {
  399. tests := []struct {
  400. name string
  401. queryParams string
  402. expectedStatusCode int
  403. checkRedirect func(*testing.T, *httptest.ResponseRecorder)
  404. }{
  405. {
  406. name: "Redirect_OfflineUser",
  407. // No aimsid, so there is no OSCAR session to query on behalf of and
  408. // the target resolves to offline.
  409. queryParams: "name=offlineuser",
  410. expectedStatusCode: http.StatusFound,
  411. checkRedirect: func(t *testing.T, rr *httptest.ResponseRecorder) {
  412. location := rr.Header().Get("Location")
  413. assert.Contains(t, location, "offline")
  414. },
  415. },
  416. {
  417. name: "Error_MissingName",
  418. queryParams: "",
  419. expectedStatusCode: http.StatusBadRequest,
  420. },
  421. }
  422. for _, tt := range tests {
  423. t.Run(tt.name, func(t *testing.T) {
  424. handler := &PresenceHandler{
  425. SessionManager: NewSessionManager(),
  426. LocateService: newMockLocateService(t),
  427. Logger: slog.Default(),
  428. }
  429. reqURL := "/presence/icon"
  430. if tt.queryParams != "" {
  431. reqURL += "?" + tt.queryParams
  432. }
  433. req, err := http.NewRequest("GET", reqURL, nil)
  434. assert.NoError(t, err)
  435. rr := httptest.NewRecorder()
  436. handler.Icon(rr, req)
  437. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  438. if tt.checkRedirect != nil {
  439. tt.checkRedirect(t, rr)
  440. }
  441. })
  442. }
  443. }
  444. func TestPresenceHandler_SetProfile(t *testing.T) {
  445. oscarInstance := state.NewSession().AddInstance()
  446. tests := []struct {
  447. name string
  448. queryParams string
  449. setupMocks func(*mockLocateService)
  450. expectedStatusCode int
  451. checkResponse func(*testing.T, string)
  452. }{
  453. {
  454. name: "Success_SetProfile",
  455. queryParams: "profile=Hello+World",
  456. setupMocks: func(ls *mockLocateService) {
  457. ls.EXPECT().SetInfo(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
  458. },
  459. expectedStatusCode: http.StatusOK,
  460. checkResponse: func(t *testing.T, body string) {
  461. assert.Contains(t, body, `"statusCode":200`)
  462. },
  463. },
  464. {
  465. name: "Error_ProfileTooLarge",
  466. queryParams: "profile=" + strings.Repeat("x", 4097),
  467. setupMocks: func(ls *mockLocateService) {},
  468. expectedStatusCode: http.StatusBadRequest,
  469. checkResponse: func(t *testing.T, body string) {
  470. assert.Contains(t, body, "profile too large")
  471. },
  472. },
  473. }
  474. for _, tt := range tests {
  475. t.Run(tt.name, func(t *testing.T) {
  476. locateService := newMockLocateService(t)
  477. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  478. handler := &PresenceHandler{
  479. SessionManager: sessionMgr,
  480. LocateService: locateService,
  481. Logger: slog.Default(),
  482. }
  483. tt.setupMocks(locateService)
  484. reqURL := "/presence/setProfile?aimsid=" + aimsid + "&" + tt.queryParams
  485. req, err := http.NewRequest("GET", reqURL, nil)
  486. assert.NoError(t, err)
  487. rr := httptest.NewRecorder()
  488. requireSession(handler.SessionManager, handler.SetProfile).ServeHTTP(rr, req)
  489. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  490. if tt.checkResponse != nil {
  491. responseBody := strings.TrimSpace(rr.Body.String())
  492. tt.checkResponse(t, responseBody)
  493. }
  494. })
  495. }
  496. }
  497. func TestPresenceHandler_GetProfile(t *testing.T) {
  498. locateService := newMockLocateService(t)
  499. oscarInstance := state.NewSession().AddInstance()
  500. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  501. handler := &PresenceHandler{
  502. SessionManager: sessionMgr,
  503. LocateService: locateService,
  504. Logger: slog.Default(),
  505. }
  506. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
  507. Return(wire.SNACMessage{
  508. Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  509. LocateInfo: wire.TLVRestBlock{
  510. TLVList: wire.TLVList{
  511. wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "My profile"),
  512. },
  513. },
  514. },
  515. }, nil)
  516. req, err := http.NewRequest("GET", "/presence/getProfile?aimsid="+aimsid, nil)
  517. assert.NoError(t, err)
  518. rr := httptest.NewRecorder()
  519. requireSession(handler.SessionManager, handler.GetProfile).ServeHTTP(rr, req)
  520. assert.Equal(t, http.StatusOK, rr.Code)
  521. body := rr.Body.String()
  522. assert.Contains(t, body, `"statusCode":200`)
  523. assert.Contains(t, body, `"My profile"`)
  524. assert.Contains(t, body, `"testuser"`)
  525. }
  526. // queuedMyInfo returns the myInfo event the session has queued, if any.
  527. // queuedMyInfo returns the myInfo event the session has queued, if any.
  528. func queuedMyInfo(session *Session) *MyInfo {
  529. var myInfo *MyInfo
  530. for _, event := range session.EventQueue.GetAllEvents() {
  531. if event.Type == "myInfo" {
  532. myInfo, _ = event.Data.(*MyInfo)
  533. }
  534. }
  535. return myInfo
  536. }
  537. func TestPresenceHandler_SetState_Occupied(t *testing.T) {
  538. // ICQ's Busy, which is a selectable connect state and must be accepted. An AIM
  539. // caller is told "away" instead, since AIM 8 draws "occupied" as offline.
  540. tests := []struct {
  541. name string
  542. screenName string
  543. wantState string
  544. }{
  545. {name: "icq account", screenName: "100003", wantState: "occupied"},
  546. {name: "aim account", screenName: "testuser", wantState: "away"},
  547. }
  548. for _, tt := range tests {
  549. t.Run(tt.name, func(t *testing.T) {
  550. // The account that decides the reported state is read off the OSCAR session.
  551. oscarSession := state.NewSession()
  552. oscarSession.SetIdentScreenName(state.NewIdentScreenName(tt.screenName))
  553. oscarInstance := oscarSession.AddInstance()
  554. sessionMgr, aimsid := createTestSessionManagerWithOSCAR(tt.screenName, oscarInstance)
  555. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  556. assert.NoError(t, err)
  557. broadcaster := newMockBuddyBroadcaster(t)
  558. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  559. handler := &PresenceHandler{
  560. SessionManager: sessionMgr,
  561. BuddyBroadcaster: broadcaster,
  562. Logger: slog.Default(),
  563. }
  564. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&view=occupied&away=", nil)
  565. assert.NoError(t, err)
  566. rr := httptest.NewRecorder()
  567. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  568. assert.Equal(t, http.StatusOK, rr.Code)
  569. // The Busy bit reaches OSCAR either way; only the reported state differs.
  570. assert.Equal(t, wire.OServiceUserStatusBusy, oscarInstance.UserStatusBitmask())
  571. assert.Contains(t, rr.Body.String(), `"state":"`+tt.wantState+`"`)
  572. // The state must survive the round trip: a later myInfo push reads it back
  573. // through currentWebState, and "online" there would undo the change.
  574. assert.Equal(t, tt.wantState, currentWebState(oscarInstance))
  575. myInfo := queuedMyInfo(session)
  576. assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
  577. assert.Equal(t, tt.wantState, myInfo.State)
  578. })
  579. }
  580. }
  581. func TestPresenceHandler_GetPresence_MdirAttachesProfile(t *testing.T) {
  582. // Search results are populated from this call, and a user carrying no nested
  583. // "profile" object is dropped by clients, so a found user would not render.
  584. ctx := context.Background()
  585. dirReply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  586. dirReply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Bob"))
  587. dirReply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Smith"))
  588. dirReply.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
  589. feedbagService := newMockFeedbagService(t)
  590. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  591. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  592. locateService := newMockLocateService(t)
  593. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  594. Return(onlineUserInfoReply("founduser", 0), nil)
  595. locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  596. Return(wire.SNACMessage{Body: dirReply}, nil)
  597. oscarInstance := state.NewSession().AddInstance()
  598. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  599. _, err := sessionMgr.GetSession(ctx, aimsid)
  600. require.NoError(t, err)
  601. handler := &PresenceHandler{
  602. SessionManager: sessionMgr,
  603. FeedbagService: feedbagService,
  604. LocateService: locateService,
  605. Logger: slog.Default(),
  606. }
  607. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=founduser", nil)
  608. require.NoError(t, err)
  609. rr := httptest.NewRecorder()
  610. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  611. require.Equal(t, http.StatusOK, rr.Code)
  612. var got struct {
  613. Response struct {
  614. Data struct {
  615. Users []struct {
  616. AimID string `json:"aimId"`
  617. Profile *BuddyProfileInfo `json:"profile"`
  618. } `json:"users"`
  619. } `json:"data"`
  620. } `json:"response"`
  621. }
  622. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  623. require.Len(t, got.Response.Data.Users, 1)
  624. profile := got.Response.Data.Users[0].Profile
  625. require.NotNil(t, profile, "mdir=1 must carry a profile object")
  626. assert.Equal(t, "Bob", profile.FirstName)
  627. assert.Equal(t, "Smith", profile.LastName)
  628. require.Len(t, profile.HomeAddress, 1)
  629. assert.Equal(t, "Reno", profile.HomeAddress[0].City)
  630. }
  631. func TestPresenceHandler_GetPresence_MdirProfileIsEmptyNotAbsent(t *testing.T) {
  632. // A user with no directory record must still render, or a freshly created
  633. // account can never be found.
  634. ctx := context.Background()
  635. feedbagService := newMockFeedbagService(t)
  636. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  637. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  638. locateService := newMockLocateService(t)
  639. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  640. Return(onlineUserInfoReply("blankuser", 0), nil)
  641. locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  642. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil)
  643. oscarInstance := state.NewSession().AddInstance()
  644. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  645. _, err := sessionMgr.GetSession(ctx, aimsid)
  646. require.NoError(t, err)
  647. handler := &PresenceHandler{
  648. SessionManager: sessionMgr,
  649. FeedbagService: feedbagService,
  650. LocateService: locateService,
  651. Logger: slog.Default(),
  652. }
  653. req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=blankuser", nil)
  654. rr := httptest.NewRecorder()
  655. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  656. // The key must be present and non-null, which is what the client tests for.
  657. assert.Contains(t, rr.Body.String(), `"profile":{}`)
  658. }
  659. func TestPresenceHandler_GetPresence_NoMdirOmitsProfile(t *testing.T) {
  660. // Without mdir the directory is not consulted at all — the mock asserts that by
  661. // having no DirInfo expectation.
  662. ctx := context.Background()
  663. feedbagService := newMockFeedbagService(t)
  664. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  665. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  666. locateService := newMockLocateService(t)
  667. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  668. Return(onlineUserInfoReply("someuser", 0), nil)
  669. oscarInstance := state.NewSession().AddInstance()
  670. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  671. _, err := sessionMgr.GetSession(ctx, aimsid)
  672. require.NoError(t, err)
  673. handler := &PresenceHandler{
  674. SessionManager: sessionMgr,
  675. FeedbagService: feedbagService,
  676. LocateService: locateService,
  677. Logger: slog.Default(),
  678. }
  679. req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=someuser", nil)
  680. rr := httptest.NewRecorder()
  681. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  682. assert.NotContains(t, rr.Body.String(), `"profile"`)
  683. }
  684. func TestPresenceHandler_GetPresence_EmptyQueryRendersBothArrays(t *testing.T) {
  685. ctx := context.Background()
  686. // No expectations on either service: naming no list must cost no lookups.
  687. feedbagService := newMockFeedbagService(t)
  688. locateService := newMockLocateService(t)
  689. oscarInstance := state.NewSession().AddInstance()
  690. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  691. _, err := sessionMgr.GetSession(ctx, aimsid)
  692. require.NoError(t, err)
  693. handler := &PresenceHandler{
  694. SessionManager: sessionMgr,
  695. FeedbagService: feedbagService,
  696. LocateService: locateService,
  697. Logger: slog.Default(),
  698. }
  699. req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&f=json&mdir=1", nil)
  700. rr := httptest.NewRecorder()
  701. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  702. assert.Equal(t, http.StatusOK, rr.Code)
  703. assert.Contains(t, rr.Body.String(), `"users":[]`)
  704. assert.Contains(t, rr.Body.String(), `"groups":[]`)
  705. }
  706. func TestPresenceHandler_GetPresence_TruncatesOversizedTargetList(t *testing.T) {
  707. ctx := context.Background()
  708. feedbagService := newMockFeedbagService(t)
  709. var queried int
  710. locateService := newMockLocateService(t)
  711. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  712. RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
  713. queried++
  714. return onlineUserInfoReply(body.ScreenName, 0), nil
  715. })
  716. oscarInstance := state.NewSession().AddInstance()
  717. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  718. _, err := sessionMgr.GetSession(ctx, aimsid)
  719. require.NoError(t, err)
  720. handler := &PresenceHandler{
  721. SessionManager: sessionMgr,
  722. FeedbagService: feedbagService,
  723. LocateService: locateService,
  724. Logger: slog.Default(),
  725. }
  726. query := "/presence/get?aimsid=" + aimsid + "&f=json"
  727. for i := range maxPresenceTargets + 8 {
  728. query += fmt.Sprintf("&t=user%d", i)
  729. }
  730. req, _ := http.NewRequest("GET", query, nil)
  731. rr := httptest.NewRecorder()
  732. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  733. assert.Equal(t, http.StatusOK, rr.Code)
  734. assert.Contains(t, rr.Body.String(), `"statusCode":200`)
  735. assert.Equal(t, maxPresenceTargets, queried, "the list is cut to the cap, not refused")
  736. // A full search page has to fit: 20 profiles plus the searched-for UIN.
  737. assert.GreaterOrEqual(t, maxPresenceTargets, 21)
  738. }
  739. // searchPageTargets builds n distinct screen names, standing in for the hits of a
  740. // member-directory search page.
  741. func searchPageTargets(n int) []string {
  742. names := make([]string, 0, n)
  743. for i := range n {
  744. names = append(names, fmt.Sprintf("user%d", i))
  745. }
  746. return names
  747. }
  748. // setStatusCaps drives setStatus and returns the capability list that reached
  749. // LocateService.SetInfo, or nil when SetInfo was never called.
  750. func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string) ([][16]byte, *Session, int) {
  751. t.Helper()
  752. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
  753. var gotCaps [][16]byte
  754. var called bool
  755. locate := newMockLocateService(t)
  756. locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).
  757. Run(func(_ context.Context, _ *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) {
  758. called = true
  759. b, ok := inBody.Bytes(wire.LocateTLVTagsInfoCapabilities)
  760. require.True(t, ok, "setStatus must always send a capabilities TLV")
  761. require.Zero(t, len(b)%16)
  762. gotCaps = userInfoCaps(wire.TLVUserInfo{
  763. TLVBlock: wire.TLVBlock{
  764. TLVList: wire.TLVList{wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, b)},
  765. },
  766. })
  767. // Apply it, as the real service would, so a follow-up call sees it.
  768. instance.SetCaps(gotCaps)
  769. }).Return(nil).Maybe()
  770. handler := &PresenceHandler{
  771. SessionManager: sessionMgr,
  772. LocateService: locate,
  773. Logger: slog.Default(),
  774. }
  775. req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+query, nil)
  776. require.NoError(t, err)
  777. rr := httptest.NewRecorder()
  778. requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
  779. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  780. require.NoError(t, err)
  781. if !called {
  782. return nil, session, rr.Code
  783. }
  784. return gotCaps, session, rr.Code
  785. }
  786. func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
  787. tests := []struct {
  788. name string
  789. // query is appended to the request, after aimsid.
  790. query string
  791. // wantCaps is the capability list that must reach SetInfo. A nil value
  792. // means SetInfo must not be called at all.
  793. wantCaps [][16]byte
  794. // wantMoodIcon is the id parameter expected on the queued myInfo, or ""
  795. // for no mood.
  796. wantMoodID string
  797. // wantCode defaults to 200.
  798. wantCode int
  799. }{
  800. {
  801. name: "a known mood is advertised as its capability",
  802. query: "&mood=0icqmood6",
  803. wantCaps: [][16]byte{wire.CapXStatusPlate},
  804. wantMoodID: "0icqmood6",
  805. },
  806. {
  807. name: "a mood with only a placeholder capability still resolves",
  808. query: "&mood=0icqmood13",
  809. wantCaps: [][16]byte{wire.CapMoodHavingFun},
  810. wantMoodID: "0icqmood13",
  811. },
  812. {
  813. // The client sends mood= alongside every plain state change, so this
  814. // is the path back to a moodless online/away/invisible.
  815. name: "an empty mood clears the capability",
  816. query: "&mood=",
  817. wantCaps: [][16]byte{},
  818. },
  819. {
  820. // A token the server cannot map is a client bug, not a reset: the
  821. // mood the user is actually showing is left alone.
  822. name: "an unrecognized mood is rejected",
  823. query: "&mood=0icqmood999",
  824. wantCode: http.StatusBadRequest,
  825. },
  826. {
  827. // A status-message-only update must leave the mood alone.
  828. name: "an absent mood parameter leaves capabilities untouched",
  829. query: "&statusMsg=hello",
  830. },
  831. }
  832. for _, tt := range tests {
  833. t.Run(tt.name, func(t *testing.T) {
  834. instance := state.NewSession().AddInstance()
  835. gotCaps, session, code := setStatusCaps(t, instance, tt.query)
  836. if tt.wantCode != 0 {
  837. assert.Equal(t, tt.wantCode, code)
  838. assert.Nil(t, gotCaps, "a rejected mood must not reach SetInfo")
  839. return
  840. }
  841. assert.Equal(t, http.StatusOK, code)
  842. assert.Equal(t, tt.wantCaps, gotCaps)
  843. myInfo := queuedMyInfo(session)
  844. require.NotNil(t, myInfo, "setStatus must queue a myInfo event")
  845. if tt.wantMoodID == "" {
  846. assert.Empty(t, myInfo.MoodIcon)
  847. return
  848. }
  849. assert.Equal(t, "/mood?id="+wire.MoodIconID(tt.wantMoodID), myInfo.MoodIcon)
  850. })
  851. }
  852. }
  853. func TestPresenceHandler_SetStatus_MoodReplacesRatherThanAccumulates(t *testing.T) {
  854. instance := state.NewSession().AddInstance()
  855. _, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
  856. assert.Equal(t, http.StatusOK, code)
  857. gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood4")
  858. assert.Equal(t, http.StatusOK, code)
  859. assert.Equal(t, [][16]byte{wire.CapXStatusBeer}, gotCaps, "the previous mood must be dropped")
  860. }
  861. func TestPresenceHandler_SetStatus_PreservesNonMoodCaps(t *testing.T) {
  862. // The capability list is rewritten wholesale, so anything the instance
  863. // advertises that is not a mood has to be carried over.
  864. instance := state.NewSession().AddInstance()
  865. instance.SetCaps([][16]byte{wire.CapChat, wire.CapXStatusBeer})
  866. gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
  867. assert.Equal(t, http.StatusOK, code)
  868. assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusPlate}, gotCaps)
  869. gotCaps, _, code = setStatusCaps(t, instance, "&mood=")
  870. assert.Equal(t, http.StatusOK, code)
  871. assert.Equal(t, [][16]byte{wire.CapChat}, gotCaps, "clearing a mood must keep the other caps")
  872. }
  873. func TestPresenceHandler_SetStatus_SetInfoError(t *testing.T) {
  874. instance := state.NewSession().AddInstance()
  875. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
  876. locate := newMockLocateService(t)
  877. locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(io.EOF)
  878. handler := &PresenceHandler{
  879. SessionManager: sessionMgr,
  880. LocateService: locate,
  881. Logger: slog.Default(),
  882. }
  883. req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&mood=0icqmood6", nil)
  884. require.NoError(t, err)
  885. rr := httptest.NewRecorder()
  886. requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
  887. assert.Equal(t, http.StatusInternalServerError, rr.Code)
  888. }
  889. func TestPresenceHandler_SetState_MyInfoCarriesBuddyIcon(t *testing.T) {
  890. // Mandarin replaces its identity from each myInfo rather than merging into it,
  891. // clearing its avatar when buddyIcon is absent.
  892. oscarInstance := state.NewSession().AddInstance()
  893. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  894. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  895. require.NoError(t, err)
  896. session.BaseURL = "http://api.example.com"
  897. broadcaster := newMockBuddyBroadcaster(t)
  898. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  899. iconRetriever := newMockBuddyIconRetriever(t)
  900. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("testuser")).
  901. Return(bartID([]byte{0xab, 0xcd}), nil)
  902. handler := &PresenceHandler{
  903. SessionManager: sessionMgr,
  904. BuddyBroadcaster: broadcaster,
  905. IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
  906. Logger: slog.Default(),
  907. }
  908. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away", nil)
  909. require.NoError(t, err)
  910. rr := httptest.NewRecorder()
  911. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  912. require.Equal(t, http.StatusOK, rr.Code)
  913. myInfo := queuedMyInfo(session)
  914. require.NotNil(t, myInfo, "expected a myInfo event to be queued")
  915. assert.Equal(t, "http://api.example.com/expressions/get?t=testuser&type=buddyIcon&bartId=abcd", myInfo.BuddyIcon)
  916. }