presence_test.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  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_BuddyListGrouping verifies that bl=1 places
  214. // each buddy under its own group using realistic feedbag data, where group rows
  215. // carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
  216. // GroupIDs. This is the shape the OSCAR feedbag actually stores.
  217. func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
  218. feedbagService := &MockFeedbagService{}
  219. locateService := &MockLocateService{}
  220. items := []wire.FeedbagItem{
  221. // Root order group: ItemID 0, GroupID 0, empty name — not a real buddy group.
  222. {ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
  223. // Named groups: ItemID 0, distinct nonzero GroupIDs.
  224. {ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  225. {ItemID: 0, GroupID: 20, ClassID: wire.FeedbagClassIdGroup, Name: "Work"},
  226. // Buddies reference their group's GroupID.
  227. {ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
  228. {ItemID: 201, GroupID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
  229. }
  230. feedbagService.On("Query", mock.Anything, mock.Anything, mock.Anything).
  231. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
  232. locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("alice")).
  233. Return(onlineUserInfoReply("alice", 0), nil)
  234. locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("bob")).
  235. Return(onlineUserInfoReply("bob", 0), nil)
  236. oscarInstance := state.NewSession().AddInstance()
  237. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  238. handler := &PresenceHandler{
  239. SessionManager: sessionMgr,
  240. FeedbagService: feedbagService,
  241. LocateService: locateService,
  242. Logger: slog.Default(),
  243. }
  244. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
  245. assert.NoError(t, err)
  246. rr := httptest.NewRecorder()
  247. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  248. assert.Equal(t, http.StatusOK, rr.Code)
  249. var parsed struct {
  250. Response struct {
  251. Data struct {
  252. Groups []struct {
  253. Name string `json:"name"`
  254. Buddies []struct {
  255. AimID string `json:"aimId"`
  256. } `json:"buddies"`
  257. } `json:"groups"`
  258. } `json:"data"`
  259. } `json:"response"`
  260. }
  261. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &parsed))
  262. // Build name -> set of buddy aimIds.
  263. byGroup := map[string][]string{}
  264. for _, g := range parsed.Response.Data.Groups {
  265. for _, b := range g.Buddies {
  266. byGroup[g.Name] = append(byGroup[g.Name], b.AimID)
  267. }
  268. }
  269. // Exactly the two named groups appear; the root group is excluded.
  270. assert.Len(t, parsed.Response.Data.Groups, 2)
  271. assert.Equal(t, []string{"alice"}, byGroup["Friends"])
  272. assert.Equal(t, []string{"bob"}, byGroup["Work"])
  273. feedbagService.AssertExpectations(t)
  274. locateService.AssertExpectations(t)
  275. }
  276. func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
  277. handler := &PresenceHandler{
  278. SessionManager: state.NewWebAPISessionManager(),
  279. Logger: slog.Default(),
  280. }
  281. req, err := http.NewRequest("GET", "/presence/get", nil)
  282. assert.NoError(t, err)
  283. rr := httptest.NewRecorder()
  284. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  285. assert.Equal(t, http.StatusBadRequest, rr.Code)
  286. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  287. }
  288. func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
  289. handler := &PresenceHandler{
  290. SessionManager: state.NewWebAPISessionManager(),
  291. Logger: slog.Default(),
  292. }
  293. req, err := http.NewRequest("GET", "/presence/get?aimsid=nonexistent", nil)
  294. assert.NoError(t, err)
  295. rr := httptest.NewRecorder()
  296. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  297. assert.Equal(t, http.StatusUnauthorized, rr.Code)
  298. assert.Contains(t, rr.Body.String(), "invalid or expired session")
  299. }
  300. func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
  301. handler := &PresenceHandler{
  302. SessionManager: state.NewWebAPISessionManager(),
  303. Logger: slog.Default(),
  304. }
  305. req, err := http.NewRequest("GET", "/presence/setState", nil)
  306. assert.NoError(t, err)
  307. rr := httptest.NewRecorder()
  308. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  309. assert.Equal(t, http.StatusBadRequest, rr.Code)
  310. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  311. }
  312. func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
  313. oscarInstance := state.NewSession().AddInstance()
  314. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  315. handler := &PresenceHandler{
  316. SessionManager: sessionMgr,
  317. Logger: slog.Default(),
  318. }
  319. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=bogus", nil)
  320. assert.NoError(t, err)
  321. rr := httptest.NewRecorder()
  322. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  323. assert.Equal(t, http.StatusBadRequest, rr.Code)
  324. assert.Contains(t, rr.Body.String(), "invalid state parameter")
  325. }
  326. func TestPresenceHandler_SetState_EmitsMyInfoEvent(t *testing.T) {
  327. // The AIM client re-renders its own status badge only from "myInfo" events,
  328. // so setState must queue one on the user's own session for the change to be
  329. // visible in their UI.
  330. oscarInstance := state.NewSession().AddInstance()
  331. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  332. broadcaster := &MockBuddyBroadcaster{}
  333. broadcaster.On("BroadcastBuddyArrived", mock.Anything, mock.Anything, mock.Anything).Return(nil)
  334. handler := &PresenceHandler{
  335. SessionManager: sessionMgr,
  336. BuddyBroadcaster: broadcaster,
  337. Logger: slog.Default(),
  338. }
  339. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away&awayMsg=brb", nil)
  340. assert.NoError(t, err)
  341. rr := httptest.NewRecorder()
  342. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  343. assert.Equal(t, http.StatusOK, rr.Code)
  344. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  345. assert.NoError(t, err)
  346. var myInfo map[string]interface{}
  347. for _, event := range session.EventQueue.GetAllEvents() {
  348. if event.Type == "myInfo" {
  349. myInfo, _ = event.Data.(map[string]interface{})
  350. }
  351. }
  352. assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
  353. assert.Equal(t, "away", myInfo["state"])
  354. assert.Equal(t, "brb", myInfo["awayMsg"])
  355. assert.Equal(t, "testuser", myInfo["aimId"])
  356. }
  357. func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
  358. // The client shallow-merges myInfo onto the shared user object, so aimId must
  359. // be the normalized id while displayId and friendly keep the user's own
  360. // casing and spacing.
  361. oscarInstance := state.NewSession().AddInstance()
  362. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("Mike Kelly", oscarInstance)
  363. broadcaster := &MockBuddyBroadcaster{}
  364. broadcaster.On("BroadcastBuddyArrived", mock.Anything, mock.Anything, mock.Anything).Return(nil)
  365. handler := &PresenceHandler{
  366. SessionManager: sessionMgr,
  367. BuddyBroadcaster: broadcaster,
  368. Logger: slog.Default(),
  369. }
  370. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away", nil)
  371. assert.NoError(t, err)
  372. rr := httptest.NewRecorder()
  373. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  374. assert.Equal(t, http.StatusOK, rr.Code)
  375. // The setState response body carries the same identity fields.
  376. var resp struct {
  377. Response struct {
  378. Data map[string]interface{} `json:"data"`
  379. } `json:"response"`
  380. }
  381. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  382. assert.Equal(t, "mikekelly", resp.Response.Data["aimId"])
  383. assert.Equal(t, "Mike Kelly", resp.Response.Data["displayId"])
  384. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  385. assert.NoError(t, err)
  386. var myInfo map[string]interface{}
  387. for _, event := range session.EventQueue.GetAllEvents() {
  388. if event.Type == "myInfo" {
  389. myInfo, _ = event.Data.(map[string]interface{})
  390. }
  391. }
  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 TestPresenceHandler_SetState_NoOSCARSession_Rejected(t *testing.T) {
  398. // A nil OSCARSession is a broken server invariant (guests are unsupported),
  399. // so the session middleware rejects it with a 500 before the handler runs.
  400. sessionMgr, aimsid := createTestSessionManager("testuser")
  401. handler := &PresenceHandler{
  402. SessionManager: sessionMgr,
  403. Logger: slog.Default(),
  404. }
  405. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=online", nil)
  406. assert.NoError(t, err)
  407. rr := httptest.NewRecorder()
  408. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  409. assert.Equal(t, http.StatusInternalServerError, rr.Code)
  410. assert.Contains(t, rr.Body.String(), "internal server error")
  411. }
  412. func TestIsICQScreenName(t *testing.T) {
  413. tests := []struct {
  414. name string
  415. screenName string
  416. expected bool
  417. }{
  418. {"ICQ_Number", "123456789", true},
  419. {"AIM_Name", "cooluser", false},
  420. {"AIM_WithNumbers", "cool123", false},
  421. {"Empty", "", false},
  422. {"Single_Digit", "5", true},
  423. {"Mixed_Chars", "12abc34", false},
  424. }
  425. for _, tt := range tests {
  426. t.Run(tt.name, func(t *testing.T) {
  427. assert.Equal(t, tt.expected, isICQScreenName(tt.screenName))
  428. })
  429. }
  430. }
  431. func TestPresenceHandler_Icon(t *testing.T) {
  432. tests := []struct {
  433. name string
  434. queryParams string
  435. expectedStatusCode int
  436. checkRedirect func(*testing.T, *httptest.ResponseRecorder)
  437. }{
  438. {
  439. name: "Redirect_OfflineUser",
  440. // No aimsid, so there is no OSCAR session to query on behalf of and
  441. // the target resolves to offline.
  442. queryParams: "name=offlineuser",
  443. expectedStatusCode: http.StatusFound,
  444. checkRedirect: func(t *testing.T, rr *httptest.ResponseRecorder) {
  445. location := rr.Header().Get("Location")
  446. assert.Contains(t, location, "offline")
  447. },
  448. },
  449. {
  450. name: "Error_MissingName",
  451. queryParams: "",
  452. expectedStatusCode: http.StatusBadRequest,
  453. },
  454. }
  455. for _, tt := range tests {
  456. t.Run(tt.name, func(t *testing.T) {
  457. handler := &PresenceHandler{
  458. SessionManager: state.NewWebAPISessionManager(),
  459. LocateService: &MockLocateService{},
  460. Logger: slog.Default(),
  461. }
  462. reqURL := "/presence/icon"
  463. if tt.queryParams != "" {
  464. reqURL += "?" + tt.queryParams
  465. }
  466. req, err := http.NewRequest("GET", reqURL, nil)
  467. assert.NoError(t, err)
  468. rr := httptest.NewRecorder()
  469. handler.Icon(rr, req)
  470. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  471. if tt.checkRedirect != nil {
  472. tt.checkRedirect(t, rr)
  473. }
  474. })
  475. }
  476. }
  477. func TestPresenceHandler_SetProfile(t *testing.T) {
  478. oscarInstance := state.NewSession().AddInstance()
  479. tests := []struct {
  480. name string
  481. queryParams string
  482. setupMocks func(*MockLocateService)
  483. expectedStatusCode int
  484. checkResponse func(*testing.T, string)
  485. }{
  486. {
  487. name: "Success_SetProfile",
  488. queryParams: "profile=Hello+World",
  489. setupMocks: func(ls *MockLocateService) {
  490. ls.On("SetInfo", mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
  491. },
  492. expectedStatusCode: http.StatusOK,
  493. checkResponse: func(t *testing.T, body string) {
  494. assert.Contains(t, body, `"statusCode":200`)
  495. },
  496. },
  497. {
  498. name: "Error_ProfileTooLarge",
  499. queryParams: "profile=" + strings.Repeat("x", 4097),
  500. setupMocks: func(ls *MockLocateService) {},
  501. expectedStatusCode: http.StatusBadRequest,
  502. checkResponse: func(t *testing.T, body string) {
  503. assert.Contains(t, body, "profile too large")
  504. },
  505. },
  506. }
  507. for _, tt := range tests {
  508. t.Run(tt.name, func(t *testing.T) {
  509. locateService := &MockLocateService{}
  510. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  511. handler := &PresenceHandler{
  512. SessionManager: sessionMgr,
  513. LocateService: locateService,
  514. Logger: slog.Default(),
  515. }
  516. tt.setupMocks(locateService)
  517. reqURL := "/presence/setProfile?aimsid=" + aimsid + "&" + tt.queryParams
  518. req, err := http.NewRequest("GET", reqURL, nil)
  519. assert.NoError(t, err)
  520. rr := httptest.NewRecorder()
  521. requireSession(handler.SessionManager, handler.SetProfile).ServeHTTP(rr, req)
  522. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  523. if tt.checkResponse != nil {
  524. responseBody := strings.TrimSpace(rr.Body.String())
  525. tt.checkResponse(t, responseBody)
  526. }
  527. locateService.AssertExpectations(t)
  528. })
  529. }
  530. }
  531. func TestPresenceHandler_GetProfile(t *testing.T) {
  532. locateService := &MockLocateService{}
  533. oscarInstance := state.NewSession().AddInstance()
  534. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  535. handler := &PresenceHandler{
  536. SessionManager: sessionMgr,
  537. LocateService: locateService,
  538. Logger: slog.Default(),
  539. }
  540. locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
  541. Return(wire.SNACMessage{
  542. Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  543. LocateInfo: wire.TLVRestBlock{
  544. TLVList: wire.TLVList{
  545. wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "My profile"),
  546. },
  547. },
  548. },
  549. }, nil)
  550. req, err := http.NewRequest("GET", "/presence/getProfile?aimsid="+aimsid, nil)
  551. assert.NoError(t, err)
  552. rr := httptest.NewRecorder()
  553. requireSession(handler.SessionManager, handler.GetProfile).ServeHTTP(rr, req)
  554. assert.Equal(t, http.StatusOK, rr.Code)
  555. body := rr.Body.String()
  556. assert.Contains(t, body, `"statusCode":200`)
  557. assert.Contains(t, body, `"My profile"`)
  558. assert.Contains(t, body, `"testuser"`)
  559. locateService.AssertExpectations(t)
  560. }