presence_handler_test.go 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888
  1. package webapi
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/mock"
  13. "github.com/stretchr/testify/require"
  14. "github.com/mk6i/open-oscar-server/state"
  15. "github.com/mk6i/open-oscar-server/wire"
  16. )
  17. // onlineUserInfoReply builds a locate UserInfoReply for an online user,
  18. // optionally marking them idle by the given number of minutes (0 = not idle).
  19. func onlineUserInfoReply(screenName string, idleMinutes uint16) wire.SNACMessage {
  20. info := wire.TLVUserInfo{ScreenName: screenName}
  21. if idleMinutes > 0 {
  22. info.Append(wire.NewTLVBE(wire.OServiceUserInfoIdleTime, idleMinutes))
  23. }
  24. return wire.SNACMessage{
  25. Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: info},
  26. }
  27. }
  28. // screenNameMatcher matches a UserInfoQuery request body by its target screen name.
  29. func screenNameMatcher(screenName string) any {
  30. return mock.MatchedBy(func(b wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool {
  31. return b.ScreenName == screenName
  32. })
  33. }
  34. func TestPresenceHandler_GetPresence(t *testing.T) {
  35. tests := []struct {
  36. name string
  37. queryParams string
  38. setupMocks func(*mockFeedbagService, *mockLocateService)
  39. expectedStatusCode int
  40. checkResponse func(*testing.T, string)
  41. }{
  42. {
  43. name: "Success_BuddyList",
  44. queryParams: "bl=1",
  45. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  46. // Return feedbag with a group and buddy
  47. fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  48. Return(wire.SNACMessage{
  49. Body: wire.SNAC_0x13_0x06_FeedbagReply{
  50. Items: []wire.FeedbagItem{
  51. {ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
  52. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
  53. },
  54. },
  55. }, nil)
  56. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("buddy1")).
  57. Return(onlineUserInfoReply("buddy1", 0), nil)
  58. },
  59. expectedStatusCode: http.StatusOK,
  60. checkResponse: func(t *testing.T, body string) {
  61. assert.Contains(t, body, `"statusCode":200`)
  62. assert.Contains(t, body, `"groups"`)
  63. assert.Contains(t, body, `"Friends"`)
  64. assert.Contains(t, body, `"buddy1"`)
  65. assert.Contains(t, body, `"online"`)
  66. },
  67. },
  68. {
  69. name: "Success_TargetUsers",
  70. queryParams: "t=user1,user2",
  71. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  72. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user1")).
  73. Return(onlineUserInfoReply("user1", 0), nil)
  74. // user2 is idle for 7 minutes.
  75. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user2")).
  76. Return(onlineUserInfoReply("user2", 7), nil)
  77. },
  78. expectedStatusCode: http.StatusOK,
  79. checkResponse: func(t *testing.T, body string) {
  80. assert.Contains(t, body, `"statusCode":200`)
  81. assert.Contains(t, body, `"users"`)
  82. assert.Contains(t, body, `"user1"`)
  83. assert.Contains(t, body, `"user2"`)
  84. assert.Contains(t, body, `"idle"`)
  85. },
  86. },
  87. {
  88. name: "Success_BlockedOrOfflineUser",
  89. queryParams: "t=blockeduser",
  90. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  91. // A blocked or offline user comes back as a locate error.
  92. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("blockeduser")).
  93. Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
  94. },
  95. expectedStatusCode: http.StatusOK,
  96. checkResponse: func(t *testing.T, body string) {
  97. assert.Contains(t, body, `"statusCode":200`)
  98. assert.Contains(t, body, `"blockeduser"`)
  99. assert.Contains(t, body, `"offline"`)
  100. },
  101. },
  102. {
  103. name: "Success_EmptyRequest",
  104. queryParams: "",
  105. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {},
  106. expectedStatusCode: http.StatusOK,
  107. checkResponse: func(t *testing.T, body string) {
  108. assert.Contains(t, body, `"statusCode":200`)
  109. },
  110. },
  111. {
  112. // A full search page: memberDir/search returns 20 profiles and the
  113. // client asks about every one, plus the keyword when it is a UIN.
  114. // This is the largest list a real client sends, and it must be served
  115. // whole — anything but a 200 costs the user the entire page.
  116. name: "Success_FullSearchPage",
  117. queryParams: "t=" + strings.Join(searchPageTargets(21), "&t="),
  118. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  119. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  120. RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
  121. return onlineUserInfoReply(body.ScreenName, 0), nil
  122. }).Times(21)
  123. },
  124. expectedStatusCode: http.StatusOK,
  125. checkResponse: func(t *testing.T, body string) {
  126. assert.Contains(t, body, `"statusCode":200`)
  127. assert.Contains(t, body, `"user0"`)
  128. assert.Contains(t, body, `"user20"`)
  129. },
  130. },
  131. }
  132. for _, tt := range tests {
  133. t.Run(tt.name, func(t *testing.T) {
  134. feedbagService := newMockFeedbagService(t)
  135. locateService := newMockLocateService(t)
  136. oscarInstance := state.NewSession().AddInstance()
  137. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  138. handler := &PresenceHandler{
  139. SessionManager: sessionMgr,
  140. FeedbagService: feedbagService,
  141. LocateService: locateService,
  142. Logger: slog.Default(),
  143. }
  144. tt.setupMocks(feedbagService, locateService)
  145. // Presence payloads carry the viewer's alias, so GetPresence reads the
  146. // feedbag. Registered last so a case's own Query stub takes precedence.
  147. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  148. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  149. reqURL := "/presence/get?aimsid=" + aimsid
  150. if tt.queryParams != "" {
  151. reqURL += "&" + tt.queryParams
  152. }
  153. req, err := http.NewRequest("GET", reqURL, nil)
  154. assert.NoError(t, err)
  155. rr := httptest.NewRecorder()
  156. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  157. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  158. if tt.checkResponse != nil {
  159. responseBody := strings.TrimSpace(rr.Body.String())
  160. tt.checkResponse(t, responseBody)
  161. }
  162. })
  163. }
  164. }
  165. // TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly verifies that
  166. // the icon is published only for an online, non-blocking user, and that an
  167. // offline or blocking user is never even looked up — so neither their icon nor
  168. // its hash leaks to a caller they are invisible to.
  169. func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testing.T) {
  170. ctx := context.Background()
  171. feedbagService := newMockFeedbagService(t)
  172. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  173. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  174. locateService := newMockLocateService(t)
  175. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("onlineuser")).
  176. Return(onlineUserInfoReply("onlineuser", 0), nil)
  177. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("offlineuser")).
  178. Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
  179. iconRetriever := newMockBuddyIconRetriever(t)
  180. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlineuser")).
  181. Return(bartID([]byte{0xab, 0xcd}), nil).Once()
  182. oscarInstance := state.NewSession().AddInstance()
  183. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  184. sess, err := sessionMgr.GetSession(ctx, aimsid)
  185. require.NoError(t, err)
  186. sess.BaseURL = "http://api.example.com"
  187. handler := &PresenceHandler{
  188. SessionManager: sessionMgr,
  189. FeedbagService: feedbagService,
  190. LocateService: locateService,
  191. IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
  192. Logger: slog.Default(),
  193. }
  194. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=onlineuser,offlineuser", nil)
  195. require.NoError(t, err)
  196. rr := httptest.NewRecorder()
  197. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  198. require.Equal(t, http.StatusOK, rr.Code)
  199. var got struct {
  200. Response struct {
  201. Data struct {
  202. Users []struct {
  203. AimID string `json:"aimId"`
  204. State string `json:"state"`
  205. BuddyIcon string `json:"buddyIcon"`
  206. } `json:"users"`
  207. } `json:"data"`
  208. } `json:"response"`
  209. }
  210. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  211. icons := map[string]string{}
  212. states := map[string]string{}
  213. for _, u := range got.Response.Data.Users {
  214. icons[u.AimID] = u.BuddyIcon
  215. states[u.AimID] = u.State
  216. }
  217. assert.Equal(t, "online", states["onlineuser"])
  218. assert.Equal(t,
  219. "http://api.example.com/expressions/get?t=onlineuser&type=buddyIcon&bartId=abcd",
  220. icons["onlineuser"])
  221. assert.Equal(t, "offline", states["offlineuser"])
  222. assert.Empty(t, icons["offlineuser"])
  223. iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("offlineuser"))
  224. }
  225. // TestPresenceHandler_GetPresence_BuddyListGrouping verifies that bl=1 places
  226. // each buddy under its own group using realistic feedbag data, where group rows
  227. // carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
  228. // GroupIDs. This is the shape the OSCAR feedbag actually stores.
  229. func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
  230. feedbagService := newMockFeedbagService(t)
  231. locateService := newMockLocateService(t)
  232. items := []wire.FeedbagItem{
  233. // Root order group: ItemID 0, GroupID 0, empty name — not a real buddy group.
  234. {ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
  235. // Named groups: ItemID 0, distinct nonzero GroupIDs.
  236. {ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  237. {ItemID: 0, GroupID: 20, ClassID: wire.FeedbagClassIdGroup, Name: "Work"},
  238. // Buddies reference their group's GroupID.
  239. {ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
  240. {ItemID: 201, GroupID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
  241. }
  242. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  243. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
  244. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("alice")).
  245. Return(onlineUserInfoReply("alice", 0), nil)
  246. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("bob")).
  247. Return(onlineUserInfoReply("bob", 0), nil)
  248. oscarInstance := state.NewSession().AddInstance()
  249. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  250. handler := &PresenceHandler{
  251. SessionManager: sessionMgr,
  252. FeedbagService: feedbagService,
  253. LocateService: locateService,
  254. Logger: slog.Default(),
  255. }
  256. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
  257. assert.NoError(t, err)
  258. rr := httptest.NewRecorder()
  259. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  260. assert.Equal(t, http.StatusOK, rr.Code)
  261. var parsed struct {
  262. Response struct {
  263. Data struct {
  264. Groups []struct {
  265. Name string `json:"name"`
  266. Buddies []struct {
  267. AimID string `json:"aimId"`
  268. } `json:"buddies"`
  269. } `json:"groups"`
  270. } `json:"data"`
  271. } `json:"response"`
  272. }
  273. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &parsed))
  274. // Build name -> set of buddy aimIds.
  275. byGroup := map[string][]string{}
  276. for _, g := range parsed.Response.Data.Groups {
  277. for _, b := range g.Buddies {
  278. byGroup[g.Name] = append(byGroup[g.Name], b.AimID)
  279. }
  280. }
  281. // Exactly the two named groups appear; the root group is excluded.
  282. assert.Len(t, parsed.Response.Data.Groups, 2)
  283. assert.Equal(t, []string{"alice"}, byGroup["Friends"])
  284. assert.Equal(t, []string{"bob"}, byGroup["Work"])
  285. }
  286. func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
  287. handler := &PresenceHandler{
  288. SessionManager: NewSessionManager(),
  289. Logger: slog.Default(),
  290. }
  291. req, err := http.NewRequest("GET", "/presence/get", nil)
  292. assert.NoError(t, err)
  293. rr := httptest.NewRecorder()
  294. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  295. assert.Equal(t, http.StatusBadRequest, rr.Code)
  296. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  297. }
  298. func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
  299. handler := &PresenceHandler{
  300. SessionManager: NewSessionManager(),
  301. Logger: slog.Default(),
  302. }
  303. req, err := http.NewRequest("GET", "/presence/get?aimsid=nonexistent", nil)
  304. assert.NoError(t, err)
  305. rr := httptest.NewRecorder()
  306. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  307. assert.Equal(t, http.StatusUnauthorized, rr.Code)
  308. assert.Contains(t, rr.Body.String(), "invalid or expired session")
  309. }
  310. func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
  311. handler := &PresenceHandler{
  312. SessionManager: NewSessionManager(),
  313. Logger: slog.Default(),
  314. }
  315. req, err := http.NewRequest("GET", "/presence/setState", nil)
  316. assert.NoError(t, err)
  317. rr := httptest.NewRecorder()
  318. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  319. assert.Equal(t, http.StatusBadRequest, rr.Code)
  320. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  321. }
  322. func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
  323. oscarInstance := state.NewSession().AddInstance()
  324. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  325. handler := &PresenceHandler{
  326. SessionManager: sessionMgr,
  327. Logger: slog.Default(),
  328. }
  329. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=bogus", nil)
  330. assert.NoError(t, err)
  331. rr := httptest.NewRecorder()
  332. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  333. assert.Equal(t, http.StatusBadRequest, rr.Code)
  334. assert.Contains(t, rr.Body.String(), "invalid state parameter")
  335. }
  336. func TestPresenceHandler_SetState_EmitsMyInfoEvent(t *testing.T) {
  337. // The AIM client re-renders its own status badge only from "myInfo" events,
  338. // so setState must queue one on the user's own session for the change to be
  339. // visible in their UI.
  340. oscarInstance := state.NewSession().AddInstance()
  341. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  342. broadcaster := newMockBuddyBroadcaster(t)
  343. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  344. handler := &PresenceHandler{
  345. SessionManager: sessionMgr,
  346. BuddyBroadcaster: broadcaster,
  347. Logger: slog.Default(),
  348. }
  349. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away&awayMsg=brb", nil)
  350. assert.NoError(t, err)
  351. rr := httptest.NewRecorder()
  352. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  353. assert.Equal(t, http.StatusOK, rr.Code)
  354. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  355. assert.NoError(t, err)
  356. myInfo := queuedMyInfo(session)
  357. assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
  358. assert.Equal(t, "away", myInfo.State)
  359. assert.Equal(t, "brb", myInfo.AwayMsg)
  360. assert.Equal(t, "testuser", myInfo.AimID)
  361. }
  362. func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
  363. // The client shallow-merges myInfo onto the shared user object, so aimId must
  364. // be the normalized id while displayId and friendly keep the user's own
  365. // casing and spacing.
  366. oscarInstance := state.NewSession().AddInstance()
  367. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("Mike Kelly", oscarInstance)
  368. broadcaster := newMockBuddyBroadcaster(t)
  369. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  370. handler := &PresenceHandler{
  371. SessionManager: sessionMgr,
  372. BuddyBroadcaster: broadcaster,
  373. Logger: slog.Default(),
  374. }
  375. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away", nil)
  376. assert.NoError(t, err)
  377. rr := httptest.NewRecorder()
  378. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  379. assert.Equal(t, http.StatusOK, rr.Code)
  380. // The setState response body carries the same identity fields.
  381. var resp struct {
  382. Response struct {
  383. Data map[string]interface{} `json:"data"`
  384. } `json:"response"`
  385. }
  386. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  387. assert.Equal(t, "mikekelly", resp.Response.Data["aimId"])
  388. assert.Equal(t, "Mike Kelly", resp.Response.Data["displayId"])
  389. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  390. assert.NoError(t, err)
  391. myInfo := queuedMyInfo(session)
  392. require.NotNil(t, myInfo, "expected a myInfo event to be queued")
  393. assert.Equal(t, "mikekelly", myInfo.AimID)
  394. assert.Equal(t, "Mike Kelly", myInfo.DisplayID)
  395. assert.Equal(t, "Mike Kelly", myInfo.Friendly)
  396. }
  397. func TestIsICQScreenName(t *testing.T) {
  398. tests := []struct {
  399. name string
  400. screenName string
  401. expected bool
  402. }{
  403. {"ICQ_Number", "123456789", true},
  404. {"AIM_Name", "cooluser", false},
  405. {"AIM_WithNumbers", "cool123", false},
  406. {"Empty", "", false},
  407. {"Single_Digit", "5", true},
  408. {"Mixed_Chars", "12abc34", false},
  409. }
  410. for _, tt := range tests {
  411. t.Run(tt.name, func(t *testing.T) {
  412. assert.Equal(t, tt.expected, isICQScreenName(tt.screenName))
  413. })
  414. }
  415. }
  416. func TestPresenceHandler_Icon(t *testing.T) {
  417. tests := []struct {
  418. name string
  419. queryParams string
  420. expectedStatusCode int
  421. checkRedirect func(*testing.T, *httptest.ResponseRecorder)
  422. }{
  423. {
  424. name: "Redirect_OfflineUser",
  425. // No aimsid, so there is no OSCAR session to query on behalf of and
  426. // the target resolves to offline.
  427. queryParams: "name=offlineuser",
  428. expectedStatusCode: http.StatusFound,
  429. checkRedirect: func(t *testing.T, rr *httptest.ResponseRecorder) {
  430. location := rr.Header().Get("Location")
  431. assert.Contains(t, location, "offline")
  432. },
  433. },
  434. {
  435. name: "Error_MissingName",
  436. queryParams: "",
  437. expectedStatusCode: http.StatusBadRequest,
  438. },
  439. }
  440. for _, tt := range tests {
  441. t.Run(tt.name, func(t *testing.T) {
  442. handler := &PresenceHandler{
  443. SessionManager: NewSessionManager(),
  444. LocateService: newMockLocateService(t),
  445. Logger: slog.Default(),
  446. }
  447. reqURL := "/presence/icon"
  448. if tt.queryParams != "" {
  449. reqURL += "?" + tt.queryParams
  450. }
  451. req, err := http.NewRequest("GET", reqURL, nil)
  452. assert.NoError(t, err)
  453. rr := httptest.NewRecorder()
  454. handler.Icon(rr, req)
  455. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  456. if tt.checkRedirect != nil {
  457. tt.checkRedirect(t, rr)
  458. }
  459. })
  460. }
  461. }
  462. func TestPresenceHandler_SetProfile(t *testing.T) {
  463. oscarInstance := state.NewSession().AddInstance()
  464. tests := []struct {
  465. name string
  466. queryParams string
  467. setupMocks func(*mockLocateService)
  468. expectedStatusCode int
  469. checkResponse func(*testing.T, string)
  470. }{
  471. {
  472. name: "Success_SetProfile",
  473. queryParams: "profile=Hello+World",
  474. setupMocks: func(ls *mockLocateService) {
  475. ls.EXPECT().SetInfo(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
  476. },
  477. expectedStatusCode: http.StatusOK,
  478. checkResponse: func(t *testing.T, body string) {
  479. assert.Contains(t, body, `"statusCode":200`)
  480. },
  481. },
  482. {
  483. name: "Error_ProfileTooLarge",
  484. queryParams: "profile=" + strings.Repeat("x", 4097),
  485. setupMocks: func(ls *mockLocateService) {},
  486. expectedStatusCode: http.StatusBadRequest,
  487. checkResponse: func(t *testing.T, body string) {
  488. assert.Contains(t, body, "profile too large")
  489. },
  490. },
  491. }
  492. for _, tt := range tests {
  493. t.Run(tt.name, func(t *testing.T) {
  494. locateService := newMockLocateService(t)
  495. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  496. handler := &PresenceHandler{
  497. SessionManager: sessionMgr,
  498. LocateService: locateService,
  499. Logger: slog.Default(),
  500. }
  501. tt.setupMocks(locateService)
  502. reqURL := "/presence/setProfile?aimsid=" + aimsid + "&" + tt.queryParams
  503. req, err := http.NewRequest("GET", reqURL, nil)
  504. assert.NoError(t, err)
  505. rr := httptest.NewRecorder()
  506. requireSession(handler.SessionManager, handler.SetProfile).ServeHTTP(rr, req)
  507. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  508. if tt.checkResponse != nil {
  509. responseBody := strings.TrimSpace(rr.Body.String())
  510. tt.checkResponse(t, responseBody)
  511. }
  512. })
  513. }
  514. }
  515. func TestPresenceHandler_GetProfile(t *testing.T) {
  516. locateService := newMockLocateService(t)
  517. oscarInstance := state.NewSession().AddInstance()
  518. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  519. handler := &PresenceHandler{
  520. SessionManager: sessionMgr,
  521. LocateService: locateService,
  522. Logger: slog.Default(),
  523. }
  524. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
  525. Return(wire.SNACMessage{
  526. Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  527. LocateInfo: wire.TLVRestBlock{
  528. TLVList: wire.TLVList{
  529. wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "My profile"),
  530. },
  531. },
  532. },
  533. }, nil)
  534. req, err := http.NewRequest("GET", "/presence/getProfile?aimsid="+aimsid, nil)
  535. assert.NoError(t, err)
  536. rr := httptest.NewRecorder()
  537. requireSession(handler.SessionManager, handler.GetProfile).ServeHTTP(rr, req)
  538. assert.Equal(t, http.StatusOK, rr.Code)
  539. body := rr.Body.String()
  540. assert.Contains(t, body, `"statusCode":200`)
  541. assert.Contains(t, body, `"My profile"`)
  542. assert.Contains(t, body, `"testuser"`)
  543. }
  544. // queuedMyInfo returns the myInfo event the session has queued, if any.
  545. func queuedMyInfo(session *Session) *MyInfo {
  546. var myInfo *MyInfo
  547. for _, event := range session.EventQueue.GetAllEvents() {
  548. if event.Type == "myInfo" {
  549. myInfo, _ = event.Data.(*MyInfo)
  550. }
  551. }
  552. return myInfo
  553. }
  554. func TestPresenceHandler_SetState_Occupied(t *testing.T) {
  555. // ICQ's Busy, which is a selectable connect state and must be accepted.
  556. oscarInstance := state.NewSession().AddInstance()
  557. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  558. broadcaster := newMockBuddyBroadcaster(t)
  559. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  560. handler := &PresenceHandler{
  561. SessionManager: sessionMgr,
  562. BuddyBroadcaster: broadcaster,
  563. Logger: slog.Default(),
  564. }
  565. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&view=occupied&away=", nil)
  566. assert.NoError(t, err)
  567. rr := httptest.NewRecorder()
  568. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  569. assert.Equal(t, http.StatusOK, rr.Code)
  570. assert.Equal(t, wire.OServiceUserStatusBusy, oscarInstance.UserStatusBitmask())
  571. // The state must survive the round trip: a later myInfo push reads it back
  572. // through currentWebState, and reporting "away" or "online" there would undo
  573. // the change in the user's own UI.
  574. assert.Equal(t, "occupied", currentWebState(oscarInstance))
  575. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  576. assert.NoError(t, err)
  577. myInfo := queuedMyInfo(session)
  578. assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
  579. assert.Equal(t, "occupied", myInfo.State)
  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 := 0; i < maxPresenceTargets+8; i++ {
  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 := 0; i < n; i++ {
  744. names = append(names, fmt.Sprintf("user%d", i))
  745. }
  746. return names
  747. }