presence_test.go 25 KB

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