presence_handler_test.go 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682
  1. package webapi
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "net/http"
  10. "net/http/httptest"
  11. "strings"
  12. "testing"
  13. "github.com/stretchr/testify/assert"
  14. "github.com/stretchr/testify/mock"
  15. "github.com/stretchr/testify/require"
  16. "github.com/mk6i/open-oscar-server/state"
  17. "github.com/mk6i/open-oscar-server/wire"
  18. )
  19. // onlineUserInfoReply builds a locate UserInfoReply for an online user,
  20. // optionally marking them idle by the given number of minutes (0 = not idle).
  21. func onlineUserInfoReply(screenName string, idleMinutes uint16) wire.SNACMessage {
  22. info := wire.TLVUserInfo{ScreenName: screenName}
  23. if idleMinutes > 0 {
  24. info.Append(wire.NewTLVBE(wire.OServiceUserInfoIdleTime, idleMinutes))
  25. }
  26. return wire.SNACMessage{
  27. Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: info},
  28. }
  29. }
  30. // screenNameMatcher matches a UserInfoQuery request body by its target screen name.
  31. func screenNameMatcher(screenName string) any {
  32. return mock.MatchedBy(func(b wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool {
  33. return b.ScreenName == screenName
  34. })
  35. }
  36. func TestPresenceHandler_GetPresence(t *testing.T) {
  37. tests := []struct {
  38. name string
  39. queryParams string
  40. setupMocks func(*mockFeedbagService, *mockLocateService)
  41. seedPresence func(*Session)
  42. expectedStatusCode int
  43. checkResponse func(*testing.T, string)
  44. }{
  45. {
  46. name: "Success_BuddyList",
  47. queryParams: "bl=1",
  48. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  49. // Return feedbag with a group and buddy
  50. fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  51. Return(wire.SNACMessage{
  52. Body: wire.SNAC_0x13_0x06_FeedbagReply{
  53. Items: []wire.FeedbagItem{
  54. {ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
  55. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
  56. },
  57. },
  58. }, nil)
  59. },
  60. // bl=1 is served from the presence view, so the state comes from the
  61. // buddy's arrival.
  62. seedPresence: func(sess *Session) {
  63. buddyArrives(sess, onlineBuddy("buddy1"))
  64. },
  65. expectedStatusCode: http.StatusOK,
  66. checkResponse: func(t *testing.T, body string) {
  67. assert.Contains(t, body, `"statusCode":200`)
  68. assert.Contains(t, body, `"groups"`)
  69. assert.Contains(t, body, `"Friends"`)
  70. assert.Contains(t, body, `"buddy1"`)
  71. assert.Contains(t, body, `"online"`)
  72. },
  73. },
  74. {
  75. name: "Success_TargetUsers",
  76. queryParams: "t=user1,user2",
  77. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  78. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user1")).
  79. Return(onlineUserInfoReply("user1", 0), nil)
  80. // user2 is idle for 7 minutes.
  81. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user2")).
  82. Return(onlineUserInfoReply("user2", 7), nil)
  83. },
  84. expectedStatusCode: http.StatusOK,
  85. checkResponse: func(t *testing.T, body string) {
  86. assert.Contains(t, body, `"statusCode":200`)
  87. assert.Contains(t, body, `"users"`)
  88. assert.Contains(t, body, `"user1"`)
  89. assert.Contains(t, body, `"user2"`)
  90. assert.Contains(t, body, `"idle"`)
  91. },
  92. },
  93. {
  94. name: "Success_BlockedOrOfflineUser",
  95. queryParams: "t=blockeduser",
  96. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  97. // A blocked or offline user comes back as a locate error.
  98. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("blockeduser")).
  99. Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
  100. },
  101. expectedStatusCode: http.StatusOK,
  102. checkResponse: func(t *testing.T, body string) {
  103. assert.Contains(t, body, `"statusCode":200`)
  104. assert.Contains(t, body, `"blockeduser"`)
  105. assert.Contains(t, body, `"offline"`)
  106. },
  107. },
  108. {
  109. name: "Success_EmptyRequest",
  110. queryParams: "",
  111. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {},
  112. expectedStatusCode: http.StatusOK,
  113. checkResponse: func(t *testing.T, body string) {
  114. assert.Contains(t, body, `"statusCode":200`)
  115. },
  116. },
  117. {
  118. // A full search page: memberDir/search returns 20 profiles and the
  119. // client asks about every one, plus the keyword when it is a UIN.
  120. // This is the largest list a real client sends, and it must be served
  121. // whole — anything but a 200 costs the user the entire page.
  122. name: "Success_FullSearchPage",
  123. queryParams: "t=" + strings.Join(searchPageTargets(21), "&t="),
  124. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  125. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  126. RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
  127. return onlineUserInfoReply(body.ScreenName, 0), nil
  128. }).Times(21)
  129. },
  130. expectedStatusCode: http.StatusOK,
  131. checkResponse: func(t *testing.T, body string) {
  132. assert.Contains(t, body, `"statusCode":200`)
  133. assert.Contains(t, body, `"user0"`)
  134. assert.Contains(t, body, `"user20"`)
  135. },
  136. },
  137. }
  138. for _, tt := range tests {
  139. t.Run(tt.name, func(t *testing.T) {
  140. feedbagService := newMockFeedbagService(t)
  141. locateService := newMockLocateService(t)
  142. oscarInstance := state.NewSession().AddInstance()
  143. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  144. handler := &PresenceHandler{
  145. SessionManager: sessionMgr,
  146. FeedbagService: feedbagService,
  147. LocateService: locateService,
  148. Logger: slog.Default(),
  149. }
  150. tt.setupMocks(feedbagService, locateService)
  151. // Presence payloads carry the viewer's alias, so GetPresence reads the
  152. // feedbag. Registered last so a case's own Query stub takes precedence.
  153. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  154. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  155. // Seeded after the stubs: an arrival resolves aliases off the feedbag.
  156. sess, err := sessionMgr.GetSession(context.Background(), aimsid)
  157. require.NoError(t, err)
  158. sess.FeedbagLoader = feedbagServiceLoader(feedbagService, oscarInstance)
  159. if tt.seedPresence != nil {
  160. tt.seedPresence(sess)
  161. }
  162. reqURL := "/presence/get?aimsid=" + aimsid
  163. if tt.queryParams != "" {
  164. reqURL += "&" + tt.queryParams
  165. }
  166. req, err := http.NewRequest("GET", reqURL, nil)
  167. assert.NoError(t, err)
  168. rr := httptest.NewRecorder()
  169. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  170. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  171. if tt.checkResponse != nil {
  172. responseBody := strings.TrimSpace(rr.Body.String())
  173. tt.checkResponse(t, responseBody)
  174. }
  175. })
  176. }
  177. }
  178. // TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly verifies that
  179. // the icon is published only for an online, non-blocking user, and that an
  180. // offline or blocking user is never even looked up — so neither their icon nor
  181. // its hash leaks to a caller they are invisible to.
  182. func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testing.T) {
  183. ctx := context.Background()
  184. feedbagService := newMockFeedbagService(t)
  185. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  186. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  187. locateService := newMockLocateService(t)
  188. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("onlineuser")).
  189. Return(onlineUserInfoReply("onlineuser", 0), nil)
  190. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("offlineuser")).
  191. Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
  192. iconRetriever := newMockBuddyIconRetriever(t)
  193. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlineuser")).
  194. Return(bartID([]byte{0xab, 0xcd}), nil).Once()
  195. oscarInstance := state.NewSession().AddInstance()
  196. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  197. sess, err := sessionMgr.GetSession(ctx, aimsid)
  198. require.NoError(t, err)
  199. sess.BaseURL = "http://api.example.com"
  200. handler := &PresenceHandler{
  201. SessionManager: sessionMgr,
  202. FeedbagService: feedbagService,
  203. LocateService: locateService,
  204. IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
  205. Logger: slog.Default(),
  206. }
  207. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=onlineuser,offlineuser", nil)
  208. require.NoError(t, err)
  209. rr := httptest.NewRecorder()
  210. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  211. require.Equal(t, http.StatusOK, rr.Code)
  212. var got struct {
  213. Response struct {
  214. Data struct {
  215. Users []struct {
  216. AimID string `json:"aimId"`
  217. State string `json:"state"`
  218. BuddyIcon string `json:"buddyIcon"`
  219. } `json:"users"`
  220. } `json:"data"`
  221. } `json:"response"`
  222. }
  223. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  224. icons := map[string]string{}
  225. states := map[string]string{}
  226. for _, u := range got.Response.Data.Users {
  227. icons[u.AimID] = u.BuddyIcon
  228. states[u.AimID] = u.State
  229. }
  230. assert.Equal(t, "online", states["onlineuser"])
  231. assert.Equal(t,
  232. "http://api.example.com/expressions/get?t=onlineuser&type=buddyIcon&bartId=abcd",
  233. icons["onlineuser"])
  234. assert.Equal(t, "offline", states["offlineuser"])
  235. assert.Empty(t, icons["offlineuser"])
  236. iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("offlineuser"))
  237. }
  238. // TestPresenceHandler_GetPresence_BuddyListGrouping verifies that bl=1 places
  239. // each buddy under its own group using realistic feedbag data, where group rows
  240. // carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
  241. // GroupIDs. This is the shape the OSCAR feedbag actually stores.
  242. // A buddy's status message rides in the BART TLV, next to the icon reference.
  243. func TestPresenceHandler_GetPresence_PublishesStatusMessage(t *testing.T) {
  244. ctx := context.Background()
  245. withStatus := wire.TLVUserInfo{ScreenName: "hasstatus"}
  246. withStatus.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
  247. locateService := newMockLocateService(t)
  248. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("hasstatus")).
  249. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: withStatus}}, nil)
  250. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("nostatus")).
  251. Return(onlineUserInfoReply("nostatus", 0), nil)
  252. iconRetriever := newMockBuddyIconRetriever(t)
  253. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
  254. oscarInstance := state.NewSession().AddInstance()
  255. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  256. sess, err := sessionMgr.GetSession(ctx, aimsid)
  257. require.NoError(t, err)
  258. sess.BaseURL = "http://api.example.com"
  259. handler := &PresenceHandler{
  260. SessionManager: sessionMgr,
  261. LocateService: locateService,
  262. IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
  263. Logger: slog.Default(),
  264. }
  265. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=hasstatus,nostatus", nil)
  266. require.NoError(t, err)
  267. rr := httptest.NewRecorder()
  268. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  269. require.Equal(t, http.StatusOK, rr.Code)
  270. var got struct {
  271. Response struct {
  272. Data struct {
  273. Users []struct {
  274. AimID string `json:"aimId"`
  275. StatusMsg string `json:"statusMsg"`
  276. } `json:"users"`
  277. } `json:"data"`
  278. } `json:"response"`
  279. }
  280. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  281. require.Len(t, got.Response.Data.Users, 2)
  282. byName := map[string]string{}
  283. for _, u := range got.Response.Data.Users {
  284. byName[u.AimID] = u.StatusMsg
  285. }
  286. assert.Equal(t, "brb", byName["hasstatus"])
  287. assert.Empty(t, byName["nostatus"])
  288. }
  289. func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
  290. feedbagService := newMockFeedbagService(t)
  291. locateService := newMockLocateService(t)
  292. items := []wire.FeedbagItem{
  293. // Root order group: ItemID 0, GroupID 0, empty name — not a real buddy group.
  294. {ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
  295. // Named groups: ItemID 0, distinct nonzero GroupIDs.
  296. {ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  297. {ItemID: 0, GroupID: 20, ClassID: wire.FeedbagClassIdGroup, Name: "Work"},
  298. // Buddies reference their group's GroupID.
  299. {ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
  300. {ItemID: 201, GroupID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
  301. }
  302. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  303. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
  304. oscarInstance := state.NewSession().AddInstance()
  305. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  306. sess, err := sessionMgr.GetSession(context.Background(), aimsid)
  307. require.NoError(t, err)
  308. sess.FeedbagLoader = feedbagServiceLoader(feedbagService, oscarInstance)
  309. buddyArrives(sess, onlineBuddy("alice"))
  310. buddyArrives(sess, onlineBuddy("bob"))
  311. handler := &PresenceHandler{
  312. SessionManager: sessionMgr,
  313. FeedbagService: feedbagService,
  314. LocateService: locateService,
  315. Logger: slog.Default(),
  316. }
  317. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
  318. require.NoError(t, err)
  319. rr := httptest.NewRecorder()
  320. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  321. assert.Equal(t, http.StatusOK, rr.Code)
  322. var parsed struct {
  323. Response struct {
  324. Data struct {
  325. Groups []struct {
  326. Name string `json:"name"`
  327. Buddies []struct {
  328. AimID string `json:"aimId"`
  329. } `json:"buddies"`
  330. } `json:"groups"`
  331. } `json:"data"`
  332. } `json:"response"`
  333. }
  334. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &parsed))
  335. // Build name -> set of buddy aimIds.
  336. byGroup := map[string][]string{}
  337. for _, g := range parsed.Response.Data.Groups {
  338. for _, b := range g.Buddies {
  339. byGroup[g.Name] = append(byGroup[g.Name], b.AimID)
  340. }
  341. }
  342. // Exactly the two named groups appear; the root group is excluded.
  343. assert.Len(t, parsed.Response.Data.Groups, 2)
  344. assert.Equal(t, []string{"alice"}, byGroup["Friends"])
  345. assert.Equal(t, []string{"bob"}, byGroup["Work"])
  346. }
  347. func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
  348. handler := &PresenceHandler{
  349. SessionManager: NewSessionManager(),
  350. Logger: slog.Default(),
  351. }
  352. req, err := http.NewRequest("GET", "/presence/get", nil)
  353. assert.NoError(t, err)
  354. rr := httptest.NewRecorder()
  355. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  356. assert.Equal(t, http.StatusBadRequest, rr.Code)
  357. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  358. }
  359. func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
  360. handler := &PresenceHandler{
  361. SessionManager: NewSessionManager(),
  362. Logger: slog.Default(),
  363. }
  364. req, err := http.NewRequest("GET", "/presence/get?aimsid=nonexistent", nil)
  365. assert.NoError(t, err)
  366. rr := httptest.NewRecorder()
  367. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  368. assert.Equal(t, http.StatusUnauthorized, rr.Code)
  369. assert.Contains(t, rr.Body.String(), "invalid or expired session")
  370. }
  371. func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
  372. handler := &PresenceHandler{
  373. SessionManager: NewSessionManager(),
  374. Logger: slog.Default(),
  375. }
  376. req, err := http.NewRequest("GET", "/presence/setState", nil)
  377. assert.NoError(t, err)
  378. rr := httptest.NewRecorder()
  379. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  380. assert.Equal(t, http.StatusBadRequest, rr.Code)
  381. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  382. }
  383. func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
  384. oscarInstance := state.NewSession().AddInstance()
  385. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  386. handler := &PresenceHandler{
  387. SessionManager: sessionMgr,
  388. Logger: slog.Default(),
  389. }
  390. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=bogus", nil)
  391. assert.NoError(t, err)
  392. rr := httptest.NewRecorder()
  393. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  394. assert.Equal(t, http.StatusBadRequest, rr.Code)
  395. assert.Contains(t, rr.Body.String(), "invalid state parameter")
  396. }
  397. func TestPresenceHandler_SetState_AppliesAwayState(t *testing.T) {
  398. // setState sets the away message and the status bits through the OSCAR
  399. // services; the identity badge re-renders from the myInfo the user info
  400. // update raises, so the handler queues no event of its own.
  401. oscarInstance := state.NewSession().AddInstance()
  402. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  403. locateService := newMockLocateService(t)
  404. locateService.EXPECT().
  405. SetInfo(mock.Anything, oscarInstance, wire.SNAC_0x02_0x04_LocateSetInfo{
  406. TLVRestBlock: wire.TLVRestBlock{
  407. TLVList: wire.TLVList{
  408. wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "brb"),
  409. },
  410. },
  411. }).
  412. Return(nil)
  413. oserviceService := newMockOServiceService(t)
  414. oserviceService.EXPECT().
  415. SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
  416. TLVRestBlock: wire.TLVRestBlock{
  417. TLVList: wire.TLVList{
  418. wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusAway),
  419. },
  420. },
  421. }).
  422. Return(nil)
  423. handler := &PresenceHandler{
  424. SessionManager: sessionMgr,
  425. LocateService: locateService,
  426. OServiceService: oserviceService,
  427. Logger: slog.Default(),
  428. }
  429. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away&awayMsg=brb", nil)
  430. assert.NoError(t, err)
  431. rr := httptest.NewRecorder()
  432. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  433. assert.Equal(t, http.StatusOK, rr.Code)
  434. var resp struct {
  435. Response struct {
  436. Data SetStateData `json:"data"`
  437. } `json:"response"`
  438. }
  439. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  440. assert.Equal(t, "away", resp.Response.Data.State)
  441. assert.Equal(t, "brb", resp.Response.Data.AwayMsg)
  442. assert.Equal(t, "testuser", resp.Response.Data.AimID)
  443. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  444. assert.NoError(t, err)
  445. assert.Empty(t, session.EventQueue.GetAllEvents())
  446. }
  447. func TestPresenceHandler_SetState_OnlineClearsAwayMessage(t *testing.T) {
  448. // Coming back online drops the away message, which LocateSetInfo owns, along
  449. // with the status bits.
  450. oscarInstance := state.NewSession().AddInstance()
  451. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  452. locateService := newMockLocateService(t)
  453. locateService.EXPECT().
  454. SetInfo(mock.Anything, oscarInstance, wire.SNAC_0x02_0x04_LocateSetInfo{
  455. TLVRestBlock: wire.TLVRestBlock{
  456. TLVList: wire.TLVList{
  457. wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, ""),
  458. },
  459. },
  460. }).
  461. Return(nil)
  462. oserviceService := newMockOServiceService(t)
  463. oserviceService.EXPECT().
  464. SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
  465. TLVRestBlock: wire.TLVRestBlock{
  466. TLVList: wire.TLVList{
  467. wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0)),
  468. },
  469. },
  470. }).
  471. Return(nil)
  472. handler := &PresenceHandler{
  473. SessionManager: sessionMgr,
  474. LocateService: locateService,
  475. OServiceService: oserviceService,
  476. Logger: slog.Default(),
  477. }
  478. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=online&awayMsg=brb", nil)
  479. assert.NoError(t, err)
  480. rr := httptest.NewRecorder()
  481. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  482. assert.Equal(t, http.StatusOK, rr.Code)
  483. assert.Contains(t, rr.Body.String(), `"awayMsg":""`)
  484. }
  485. func TestPresenceHandler_SetState_ServiceError(t *testing.T) {
  486. oscarInstance := state.NewSession().AddInstance()
  487. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  488. oserviceService := newMockOServiceService(t)
  489. oserviceService.EXPECT().
  490. SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  491. Return(assert.AnError)
  492. handler := &PresenceHandler{
  493. SessionManager: sessionMgr,
  494. OServiceService: oserviceService,
  495. Logger: slog.Default(),
  496. }
  497. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=dnd", nil)
  498. assert.NoError(t, err)
  499. rr := httptest.NewRecorder()
  500. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  501. assert.Equal(t, http.StatusInternalServerError, rr.Code)
  502. }
  503. func TestPresenceHandler_SetState_NormalizesAimID(t *testing.T) {
  504. // The client keys users by the normalized aimId, so the response must carry
  505. // that while displayId keeps the user's own casing and spacing.
  506. oscarInstance := state.NewSession().AddInstance()
  507. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("Mike Kelly", oscarInstance)
  508. // An away state with no message leaves the stored away message alone, so the
  509. // handler sends the status change only.
  510. oserviceService := newMockOServiceService(t)
  511. oserviceService.EXPECT().
  512. SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
  513. TLVRestBlock: wire.TLVRestBlock{
  514. TLVList: wire.TLVList{
  515. wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusAway),
  516. },
  517. },
  518. }).
  519. Return(nil)
  520. handler := &PresenceHandler{
  521. SessionManager: sessionMgr,
  522. OServiceService: oserviceService,
  523. Logger: slog.Default(),
  524. }
  525. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away", nil)
  526. assert.NoError(t, err)
  527. rr := httptest.NewRecorder()
  528. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  529. assert.Equal(t, http.StatusOK, rr.Code)
  530. // The setState response body carries the same identity fields.
  531. var resp struct {
  532. Response struct {
  533. Data map[string]any `json:"data"`
  534. } `json:"response"`
  535. }
  536. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  537. assert.Equal(t, "mikekelly", resp.Response.Data["aimId"])
  538. assert.Equal(t, "Mike Kelly", resp.Response.Data["displayId"])
  539. }
  540. func TestPresenceHandler_Icon(t *testing.T) {
  541. tests := []struct {
  542. name string
  543. queryParams string
  544. expectedStatusCode int
  545. checkRedirect func(*testing.T, *httptest.ResponseRecorder)
  546. }{
  547. {
  548. name: "Redirect_OfflineUser",
  549. // No aimsid, so there is no OSCAR session to query on behalf of and
  550. // the target resolves to offline.
  551. queryParams: "name=offlineuser",
  552. expectedStatusCode: http.StatusFound,
  553. checkRedirect: func(t *testing.T, rr *httptest.ResponseRecorder) {
  554. location := rr.Header().Get("Location")
  555. assert.Contains(t, location, "offline")
  556. },
  557. },
  558. {
  559. name: "Error_MissingName",
  560. queryParams: "",
  561. expectedStatusCode: http.StatusBadRequest,
  562. },
  563. }
  564. for _, tt := range tests {
  565. t.Run(tt.name, func(t *testing.T) {
  566. handler := &PresenceHandler{
  567. SessionManager: NewSessionManager(),
  568. LocateService: newMockLocateService(t),
  569. Logger: slog.Default(),
  570. }
  571. reqURL := "/presence/icon"
  572. if tt.queryParams != "" {
  573. reqURL += "?" + tt.queryParams
  574. }
  575. req, err := http.NewRequest("GET", reqURL, nil)
  576. assert.NoError(t, err)
  577. rr := httptest.NewRecorder()
  578. handler.Icon(rr, req)
  579. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  580. if tt.checkRedirect != nil {
  581. tt.checkRedirect(t, rr)
  582. }
  583. })
  584. }
  585. }
  586. func TestPresenceHandler_SetProfile(t *testing.T) {
  587. oscarInstance := state.NewSession().AddInstance()
  588. tests := []struct {
  589. name string
  590. queryParams string
  591. setupMocks func(*mockLocateService)
  592. expectedStatusCode int
  593. checkResponse func(*testing.T, string)
  594. }{
  595. {
  596. name: "Success_SetProfile",
  597. queryParams: "profile=Hello+World",
  598. setupMocks: func(ls *mockLocateService) {
  599. ls.EXPECT().SetInfo(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
  600. },
  601. expectedStatusCode: http.StatusOK,
  602. checkResponse: func(t *testing.T, body string) {
  603. assert.Contains(t, body, `"statusCode":200`)
  604. },
  605. },
  606. {
  607. name: "Error_ProfileTooLarge",
  608. queryParams: "profile=" + strings.Repeat("x", 4097),
  609. setupMocks: func(ls *mockLocateService) {},
  610. expectedStatusCode: http.StatusBadRequest,
  611. checkResponse: func(t *testing.T, body string) {
  612. assert.Contains(t, body, "profile too large")
  613. },
  614. },
  615. }
  616. for _, tt := range tests {
  617. t.Run(tt.name, func(t *testing.T) {
  618. locateService := newMockLocateService(t)
  619. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  620. handler := &PresenceHandler{
  621. SessionManager: sessionMgr,
  622. LocateService: locateService,
  623. Logger: slog.Default(),
  624. }
  625. tt.setupMocks(locateService)
  626. reqURL := "/presence/setProfile?aimsid=" + aimsid + "&" + tt.queryParams
  627. req, err := http.NewRequest("GET", reqURL, nil)
  628. assert.NoError(t, err)
  629. rr := httptest.NewRecorder()
  630. requireSession(handler.SessionManager, handler.SetProfile).ServeHTTP(rr, req)
  631. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  632. if tt.checkResponse != nil {
  633. responseBody := strings.TrimSpace(rr.Body.String())
  634. tt.checkResponse(t, responseBody)
  635. }
  636. })
  637. }
  638. }
  639. func TestPresenceHandler_GetProfile(t *testing.T) {
  640. locateService := newMockLocateService(t)
  641. oscarInstance := state.NewSession().AddInstance()
  642. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  643. handler := &PresenceHandler{
  644. SessionManager: sessionMgr,
  645. LocateService: locateService,
  646. Logger: slog.Default(),
  647. }
  648. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
  649. Return(wire.SNACMessage{
  650. Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  651. LocateInfo: wire.TLVRestBlock{
  652. TLVList: wire.TLVList{
  653. wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "My profile"),
  654. },
  655. },
  656. },
  657. }, nil)
  658. req, err := http.NewRequest("GET", "/presence/getProfile?aimsid="+aimsid, nil)
  659. assert.NoError(t, err)
  660. rr := httptest.NewRecorder()
  661. requireSession(handler.SessionManager, handler.GetProfile).ServeHTTP(rr, req)
  662. assert.Equal(t, http.StatusOK, rr.Code)
  663. body := rr.Body.String()
  664. assert.Contains(t, body, `"statusCode":200`)
  665. assert.Contains(t, body, `"My profile"`)
  666. assert.Contains(t, body, `"testuser"`)
  667. }
  668. func TestPresenceHandler_SetState_Occupied(t *testing.T) {
  669. // ICQ's Busy, which is a selectable connect state and must be accepted. An AIM
  670. // caller is told "away" instead, since AIM 8 draws "occupied" as offline.
  671. tests := []struct {
  672. name string
  673. screenName string
  674. wantState string
  675. }{
  676. {name: "icq account", screenName: "100003", wantState: "occupied"},
  677. {name: "aim account", screenName: "testuser", wantState: "away"},
  678. }
  679. for _, tt := range tests {
  680. t.Run(tt.name, func(t *testing.T) {
  681. // The account that decides the reported state is read off the OSCAR session.
  682. oscarSession := state.NewSession()
  683. oscarSession.SetIdentScreenName(state.NewIdentScreenName(tt.screenName))
  684. oscarInstance := oscarSession.AddInstance()
  685. sessionMgr, aimsid := createTestSessionManagerWithOSCAR(tt.screenName, oscarInstance)
  686. oserviceService := newMockOServiceService(t)
  687. oserviceService.EXPECT().
  688. SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
  689. TLVRestBlock: wire.TLVRestBlock{
  690. TLVList: wire.TLVList{
  691. wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusBusy),
  692. },
  693. },
  694. }).
  695. // stand in for the service, which applies the status to the session
  696. Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) {
  697. status, _ := inBody.Uint32BE(wire.OServiceUserInfoStatus)
  698. instance.SetUserStatusBitmask(status)
  699. }).
  700. Return(nil)
  701. handler := &PresenceHandler{
  702. SessionManager: sessionMgr,
  703. OServiceService: oserviceService,
  704. Logger: slog.Default(),
  705. }
  706. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&view=occupied&away=", nil)
  707. assert.NoError(t, err)
  708. rr := httptest.NewRecorder()
  709. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  710. assert.Equal(t, http.StatusOK, rr.Code)
  711. // The Busy bit reaches OSCAR either way; only the reported state differs.
  712. assert.Contains(t, rr.Body.String(), `"state":"`+tt.wantState+`"`)
  713. // The state must survive the round trip: the myInfo raised by the
  714. // user info update reads the Busy bit back off the wire.
  715. assert.Equal(t, tt.wantState, selfWebState(oscarSession.TLVUserInfo(), oscarSession.IdentScreenName().UIN() == 0))
  716. })
  717. }
  718. }
  719. func TestPresenceHandler_GetPresence_MdirAttachesProfile(t *testing.T) {
  720. // Search results are populated from this call, and a user carrying no nested
  721. // "profile" object is dropped by clients, so a found user would not render.
  722. ctx := context.Background()
  723. dirReply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  724. dirReply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Bob"))
  725. dirReply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Smith"))
  726. dirReply.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
  727. feedbagService := newMockFeedbagService(t)
  728. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  729. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  730. locateService := newMockLocateService(t)
  731. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  732. Return(onlineUserInfoReply("founduser", 0), nil)
  733. locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  734. Return(wire.SNACMessage{Body: dirReply}, nil)
  735. oscarInstance := state.NewSession().AddInstance()
  736. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  737. _, err := sessionMgr.GetSession(ctx, aimsid)
  738. require.NoError(t, err)
  739. handler := &PresenceHandler{
  740. SessionManager: sessionMgr,
  741. FeedbagService: feedbagService,
  742. LocateService: locateService,
  743. Logger: slog.Default(),
  744. }
  745. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=founduser", nil)
  746. require.NoError(t, err)
  747. rr := httptest.NewRecorder()
  748. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  749. require.Equal(t, http.StatusOK, rr.Code)
  750. var got struct {
  751. Response struct {
  752. Data struct {
  753. Users []struct {
  754. AimID string `json:"aimId"`
  755. Profile *BuddyProfileInfo `json:"profile"`
  756. } `json:"users"`
  757. } `json:"data"`
  758. } `json:"response"`
  759. }
  760. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  761. require.Len(t, got.Response.Data.Users, 1)
  762. profile := got.Response.Data.Users[0].Profile
  763. require.NotNil(t, profile, "mdir=1 must carry a profile object")
  764. assert.Equal(t, "Bob", profile.FirstName)
  765. assert.Equal(t, "Smith", profile.LastName)
  766. require.Len(t, profile.HomeAddress, 1)
  767. assert.Equal(t, "Reno", profile.HomeAddress[0].City)
  768. }
  769. func TestPresenceHandler_GetPresence_MdirProfileIsEmptyNotAbsent(t *testing.T) {
  770. // A user with no directory record must still render, or a freshly created
  771. // account can never be found.
  772. ctx := context.Background()
  773. feedbagService := newMockFeedbagService(t)
  774. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  775. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  776. locateService := newMockLocateService(t)
  777. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  778. Return(onlineUserInfoReply("blankuser", 0), nil)
  779. locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  780. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil)
  781. oscarInstance := state.NewSession().AddInstance()
  782. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  783. _, err := sessionMgr.GetSession(ctx, aimsid)
  784. require.NoError(t, err)
  785. handler := &PresenceHandler{
  786. SessionManager: sessionMgr,
  787. FeedbagService: feedbagService,
  788. LocateService: locateService,
  789. Logger: slog.Default(),
  790. }
  791. req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=blankuser", nil)
  792. rr := httptest.NewRecorder()
  793. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  794. // The key must be present and non-null, which is what the client tests for.
  795. assert.Contains(t, rr.Body.String(), `"profile":{}`)
  796. }
  797. func TestPresenceHandler_GetPresence_NoMdirOmitsProfile(t *testing.T) {
  798. // Without mdir the directory is not consulted at all — the mock asserts that by
  799. // having no DirInfo expectation.
  800. ctx := context.Background()
  801. feedbagService := newMockFeedbagService(t)
  802. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  803. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  804. locateService := newMockLocateService(t)
  805. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  806. Return(onlineUserInfoReply("someuser", 0), nil)
  807. oscarInstance := state.NewSession().AddInstance()
  808. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  809. _, err := sessionMgr.GetSession(ctx, aimsid)
  810. require.NoError(t, err)
  811. handler := &PresenceHandler{
  812. SessionManager: sessionMgr,
  813. FeedbagService: feedbagService,
  814. LocateService: locateService,
  815. Logger: slog.Default(),
  816. }
  817. req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=someuser", nil)
  818. rr := httptest.NewRecorder()
  819. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  820. assert.NotContains(t, rr.Body.String(), `"profile"`)
  821. }
  822. func TestPresenceHandler_GetPresence_EmptyQueryRendersBothArrays(t *testing.T) {
  823. ctx := context.Background()
  824. // No expectations on either service: naming no list must cost no lookups.
  825. feedbagService := newMockFeedbagService(t)
  826. locateService := newMockLocateService(t)
  827. oscarInstance := state.NewSession().AddInstance()
  828. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  829. _, err := sessionMgr.GetSession(ctx, aimsid)
  830. require.NoError(t, err)
  831. handler := &PresenceHandler{
  832. SessionManager: sessionMgr,
  833. FeedbagService: feedbagService,
  834. LocateService: locateService,
  835. Logger: slog.Default(),
  836. }
  837. req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&f=json&mdir=1", nil)
  838. rr := httptest.NewRecorder()
  839. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  840. assert.Equal(t, http.StatusOK, rr.Code)
  841. assert.Contains(t, rr.Body.String(), `"users":[]`)
  842. assert.Contains(t, rr.Body.String(), `"groups":[]`)
  843. }
  844. func TestPresenceHandler_GetPresence_TruncatesOversizedTargetList(t *testing.T) {
  845. ctx := context.Background()
  846. feedbagService := newMockFeedbagService(t)
  847. var queried int
  848. locateService := newMockLocateService(t)
  849. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  850. RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
  851. queried++
  852. return onlineUserInfoReply(body.ScreenName, 0), nil
  853. })
  854. oscarInstance := state.NewSession().AddInstance()
  855. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  856. _, err := sessionMgr.GetSession(ctx, aimsid)
  857. require.NoError(t, err)
  858. handler := &PresenceHandler{
  859. SessionManager: sessionMgr,
  860. FeedbagService: feedbagService,
  861. LocateService: locateService,
  862. Logger: slog.Default(),
  863. }
  864. query := "/presence/get?aimsid=" + aimsid + "&f=json"
  865. for i := range maxPresenceTargets + 8 {
  866. query += fmt.Sprintf("&t=user%d", i)
  867. }
  868. req, _ := http.NewRequest("GET", query, nil)
  869. rr := httptest.NewRecorder()
  870. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  871. assert.Equal(t, http.StatusOK, rr.Code)
  872. assert.Contains(t, rr.Body.String(), `"statusCode":200`)
  873. assert.Equal(t, maxPresenceTargets, queried, "the list is cut to the cap, not refused")
  874. // A full search page has to fit: 20 profiles plus the searched-for UIN.
  875. assert.GreaterOrEqual(t, maxPresenceTargets, 21)
  876. }
  877. // searchPageTargets builds n distinct screen names, standing in for the hits of a
  878. // member-directory search page.
  879. func searchPageTargets(n int) []string {
  880. names := make([]string, 0, n)
  881. for i := range n {
  882. names = append(names, fmt.Sprintf("user%d", i))
  883. }
  884. return names
  885. }
  886. // newSetStatusDriver returns a function that drives setStatus and reports the
  887. // capability list that reached LocateService.SetInfo, or nil when SetInfo was
  888. // never called. The web session is built once, so consecutive calls see the
  889. // capabilities the earlier ones left on it. It starts out with the sign-on
  890. // capabilities, unless caps replaces them.
  891. func newSetStatusDriver(t *testing.T, caps ...[16]byte) func(query string) ([][16]byte, int) {
  892. t.Helper()
  893. instance := state.NewSession().AddInstance()
  894. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
  895. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  896. require.NoError(t, err)
  897. if len(caps) > 0 {
  898. session.capabilities = caps
  899. }
  900. var gotCaps [][16]byte
  901. var called bool
  902. locate := newMockLocateService(t)
  903. locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).
  904. Run(func(_ context.Context, _ *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) {
  905. called = true
  906. b, ok := inBody.Bytes(wire.LocateTLVTagsInfoCapabilities)
  907. require.True(t, ok, "setStatus must always send a capabilities TLV")
  908. require.Zero(t, len(b)%16)
  909. gotCaps = userInfoCaps(wire.TLVUserInfo{
  910. TLVBlock: wire.TLVBlock{
  911. TLVList: wire.TLVList{wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, b)},
  912. },
  913. })
  914. }).Return(nil).Maybe()
  915. oservice := newMockOServiceService(t)
  916. oservice.EXPECT().
  917. SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  918. Return(nil).
  919. Maybe()
  920. handler := &PresenceHandler{
  921. SessionManager: sessionMgr,
  922. LocateService: locate,
  923. OServiceService: oservice,
  924. Logger: slog.Default(),
  925. }
  926. return func(query string) ([][16]byte, int) {
  927. gotCaps, called = nil, false
  928. req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+query, nil)
  929. require.NoError(t, err)
  930. rr := httptest.NewRecorder()
  931. requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
  932. if !called {
  933. return nil, rr.Code
  934. }
  935. return gotCaps, rr.Code
  936. }
  937. }
  938. // A state change must not blank the status message: the client renders both from
  939. // the same payload.
  940. func TestPresenceHandler_SetState_PreservesStatusMessage(t *testing.T) {
  941. oscarInstance := state.NewSession().AddInstance()
  942. oscarInstance.Session().SetStatus(testStatusBART)
  943. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  944. oservice := newMockOServiceService(t)
  945. oservice.EXPECT().
  946. SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  947. Return(nil)
  948. locate := newMockLocateService(t)
  949. locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  950. handler := &PresenceHandler{
  951. SessionManager: sessionMgr,
  952. LocateService: locate,
  953. OServiceService: oservice,
  954. Logger: slog.Default(),
  955. }
  956. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=online", nil)
  957. require.NoError(t, err)
  958. rr := httptest.NewRecorder()
  959. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  960. require.Equal(t, http.StatusOK, rr.Code)
  961. assert.Contains(t, rr.Body.String(), `"statusMsg":"brb"`)
  962. }
  963. func TestPresenceHandler_SetStatus_StatusMessage(t *testing.T) {
  964. tests := []struct {
  965. name string
  966. // query is appended to the request, after aimsid.
  967. query string
  968. // wantStatusMsg is the text that must reach OSCAR, when wantSet is true.
  969. wantStatusMsg string
  970. wantSet bool
  971. }{
  972. {
  973. name: "a status message is sent as a BART status item",
  974. query: "&statusMsg=out+to+lunch",
  975. wantStatusMsg: "out to lunch",
  976. wantSet: true,
  977. },
  978. {
  979. // The client clears the status message by sending an empty one.
  980. name: "an empty status message clears it",
  981. query: "&statusMsg=",
  982. wantSet: true,
  983. },
  984. {
  985. name: "an absent status message parameter leaves it alone",
  986. query: "&mood=",
  987. },
  988. }
  989. for _, tt := range tests {
  990. t.Run(tt.name, func(t *testing.T) {
  991. oscarInstance := state.NewSession().AddInstance()
  992. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  993. var gotBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields
  994. oservice := newMockOServiceService(t)
  995. if tt.wantSet {
  996. oservice.EXPECT().
  997. SetUserInfoFields(mock.Anything, oscarInstance, wire.SNACFrame{}, mock.Anything).
  998. Run(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, inBody wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields) {
  999. gotBody = inBody
  1000. }).
  1001. Return(nil)
  1002. }
  1003. locate := newMockLocateService(t)
  1004. locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
  1005. handler := &PresenceHandler{
  1006. SessionManager: sessionMgr,
  1007. LocateService: locate,
  1008. OServiceService: oservice,
  1009. Logger: slog.Default(),
  1010. }
  1011. req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+tt.query, nil)
  1012. require.NoError(t, err)
  1013. rr := httptest.NewRecorder()
  1014. requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
  1015. require.Equal(t, http.StatusOK, rr.Code)
  1016. if !tt.wantSet {
  1017. return
  1018. }
  1019. b, hasBART := gotBody.Bytes(wire.OServiceUserInfoBARTInfo)
  1020. require.True(t, hasBART, "setStatus must send a BART TLV")
  1021. var gotID wire.BARTID
  1022. require.NoError(t, wire.UnmarshalBE(&gotID, bytes.NewReader(b)))
  1023. assert.Equal(t, wire.BARTTypesStatusStr, gotID.Type)
  1024. assert.Equal(t, wire.BARTFlagsData, gotID.Flags)
  1025. gotStatusMsg, err := gotID.StatusText()
  1026. require.NoError(t, err)
  1027. assert.Equal(t, tt.wantStatusMsg, gotStatusMsg)
  1028. })
  1029. }
  1030. }
  1031. func TestPresenceHandler_SetStatus_StatusMessageError(t *testing.T) {
  1032. oscarInstance := state.NewSession().AddInstance()
  1033. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  1034. oservice := newMockOServiceService(t)
  1035. oservice.EXPECT().
  1036. SetUserInfoFields(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  1037. Return(io.EOF)
  1038. handler := &PresenceHandler{
  1039. SessionManager: sessionMgr,
  1040. OServiceService: oservice,
  1041. Logger: slog.Default(),
  1042. }
  1043. req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&statusMsg=nope", nil)
  1044. require.NoError(t, err)
  1045. rr := httptest.NewRecorder()
  1046. requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
  1047. assert.Equal(t, http.StatusInternalServerError, rr.Code)
  1048. assert.Contains(t, rr.Body.String(), "failed to set status")
  1049. }
  1050. func TestPresenceHandler_SetStatus_StatusMessageTooLong(t *testing.T) {
  1051. // An overlong message would wrap the one-byte length prefix and corrupt the
  1052. // user info block.
  1053. oscarInstance := state.NewSession().AddInstance()
  1054. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  1055. // No EXPECT: nothing may reach OSCAR.
  1056. handler := &PresenceHandler{
  1057. SessionManager: sessionMgr,
  1058. OServiceService: newMockOServiceService(t),
  1059. Logger: slog.Default(),
  1060. }
  1061. tooLong := strings.Repeat("a", 252)
  1062. req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&statusMsg="+tooLong, nil)
  1063. require.NoError(t, err)
  1064. rr := httptest.NewRecorder()
  1065. requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
  1066. assert.Equal(t, http.StatusBadRequest, rr.Code)
  1067. assert.Contains(t, rr.Body.String(), wire.ErrStatusTextSizeExceeded.Error())
  1068. }
  1069. func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
  1070. tests := []struct {
  1071. name string
  1072. // query is appended to the request, after aimsid.
  1073. query string
  1074. // wantCaps is the capability list that must reach SetInfo. A nil value
  1075. // means SetInfo must not be called at all.
  1076. wantCaps [][16]byte
  1077. // wantCode defaults to 200.
  1078. wantCode int
  1079. }{
  1080. {
  1081. name: "a known mood is advertised as its capability",
  1082. query: "&mood=0icqmood6",
  1083. wantCaps: [][16]byte{wire.CapICQCh2Extended, wire.CapUTF8Messages, wire.CapXStatusPlate},
  1084. },
  1085. {
  1086. name: "a mood with only a placeholder capability still resolves",
  1087. query: "&mood=0icqmood13",
  1088. wantCaps: [][16]byte{wire.CapICQCh2Extended, wire.CapUTF8Messages, wire.CapMoodHavingFun},
  1089. },
  1090. {
  1091. // The client sends mood= alongside every plain state change, so this
  1092. // is the path back to a moodless online/away/invisible. Only the
  1093. // mood is dropped.
  1094. name: "an empty mood clears the capability",
  1095. query: "&mood=",
  1096. wantCaps: [][16]byte{wire.CapICQCh2Extended, wire.CapUTF8Messages},
  1097. },
  1098. {
  1099. // A token the server cannot map is a client bug, not a reset: the
  1100. // mood the user is actually showing is left alone.
  1101. name: "an unrecognized mood is rejected",
  1102. query: "&mood=0icqmood999",
  1103. wantCode: http.StatusBadRequest,
  1104. },
  1105. {
  1106. // A status-message-only update must leave the mood alone.
  1107. name: "an absent mood parameter leaves capabilities untouched",
  1108. query: "&statusMsg=hello",
  1109. },
  1110. }
  1111. for _, tt := range tests {
  1112. t.Run(tt.name, func(t *testing.T) {
  1113. gotCaps, code := newSetStatusDriver(t)(tt.query)
  1114. if tt.wantCode != 0 {
  1115. assert.Equal(t, tt.wantCode, code)
  1116. assert.Nil(t, gotCaps, "a rejected mood must not reach SetInfo")
  1117. return
  1118. }
  1119. assert.Equal(t, http.StatusOK, code)
  1120. assert.Equal(t, tt.wantCaps, gotCaps)
  1121. })
  1122. }
  1123. }
  1124. func TestPresenceHandler_SetStatus_MoodReplacesRatherThanAccumulates(t *testing.T) {
  1125. setStatus := newSetStatusDriver(t)
  1126. _, code := setStatus("&mood=0icqmood6")
  1127. assert.Equal(t, http.StatusOK, code)
  1128. gotCaps, code := setStatus("&mood=0icqmood4")
  1129. assert.Equal(t, http.StatusOK, code)
  1130. assert.Equal(t, [][16]byte{wire.CapICQCh2Extended, wire.CapUTF8Messages, wire.CapXStatusBeer}, gotCaps,
  1131. "the previous mood must be dropped")
  1132. }
  1133. func TestPresenceHandler_SetStatus_PreservesNonMoodCaps(t *testing.T) {
  1134. // The capability list is rewritten wholesale, so anything the session
  1135. // advertises that is not a mood has to be carried over.
  1136. setStatus := newSetStatusDriver(t, wire.CapChat, wire.CapXStatusBeer)
  1137. gotCaps, code := setStatus("&mood=0icqmood6")
  1138. assert.Equal(t, http.StatusOK, code)
  1139. assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusPlate}, gotCaps)
  1140. gotCaps, code = setStatus("&mood=")
  1141. assert.Equal(t, http.StatusOK, code)
  1142. assert.Equal(t, [][16]byte{wire.CapChat}, gotCaps, "clearing a mood must keep the other caps")
  1143. }
  1144. func TestPresenceHandler_SetStatus_SetInfoError(t *testing.T) {
  1145. instance := state.NewSession().AddInstance()
  1146. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
  1147. locate := newMockLocateService(t)
  1148. locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(io.EOF)
  1149. handler := &PresenceHandler{
  1150. SessionManager: sessionMgr,
  1151. LocateService: locate,
  1152. Logger: slog.Default(),
  1153. }
  1154. req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&mood=0icqmood6", nil)
  1155. require.NoError(t, err)
  1156. rr := httptest.NewRecorder()
  1157. requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
  1158. assert.Equal(t, http.StatusInternalServerError, rr.Code)
  1159. }
  1160. // presence/get?t= serves what the view knows and only pays for a locate query
  1161. // when the caller asks for something the view cannot carry.
  1162. func TestPresenceHandler_GetPresence_TargetsFallBackToLocate(t *testing.T) {
  1163. // getTargets drives presence/get for one target and returns the single user
  1164. // the response carries.
  1165. getTargets := func(t *testing.T, target, extraParams string, seed func(*Session), setupLocate func(*mockLocateService)) struct {
  1166. AimID string `json:"aimId"`
  1167. State string `json:"state"`
  1168. AwayMsg string `json:"awayMsg"`
  1169. StatusMsg string `json:"statusMsg"`
  1170. } {
  1171. t.Helper()
  1172. feedbagService := newMockFeedbagService(t)
  1173. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  1174. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  1175. locateService := newMockLocateService(t)
  1176. setupLocate(locateService)
  1177. oscarInstance := state.NewSession().AddInstance()
  1178. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  1179. sess, err := sessionMgr.GetSession(context.Background(), aimsid)
  1180. require.NoError(t, err)
  1181. seed(sess)
  1182. handler := &PresenceHandler{
  1183. SessionManager: sessionMgr,
  1184. FeedbagService: feedbagService,
  1185. LocateService: locateService,
  1186. IconSource: newTestIconSource(t),
  1187. Logger: slog.Default(),
  1188. }
  1189. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t="+target+extraParams, nil)
  1190. require.NoError(t, err)
  1191. rr := httptest.NewRecorder()
  1192. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  1193. require.Equal(t, http.StatusOK, rr.Code)
  1194. var got struct {
  1195. Response struct {
  1196. Data struct {
  1197. Users []struct {
  1198. AimID string `json:"aimId"`
  1199. State string `json:"state"`
  1200. AwayMsg string `json:"awayMsg"`
  1201. StatusMsg string `json:"statusMsg"`
  1202. } `json:"users"`
  1203. } `json:"data"`
  1204. } `json:"response"`
  1205. }
  1206. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  1207. require.Len(t, got.Response.Data.Users, 1)
  1208. return got.Response.Data.Users[0]
  1209. }
  1210. noLocate := func(*mockLocateService) {}
  1211. t.Run("an online buddy is served from the view", func(t *testing.T) {
  1212. got := getTargets(t, "mikekelly", "", func(sess *Session) {
  1213. buddyArrives(sess, bartBuddy("Mike Kelly", testStatusBART))
  1214. }, noLocate)
  1215. assert.Equal(t, "online", got.State)
  1216. assert.Equal(t, "brb", got.StatusMsg)
  1217. })
  1218. // A target need not be a buddy, so nothing may be concluded from a miss.
  1219. t.Run("a target the view has never seen is looked up", func(t *testing.T) {
  1220. got := getTargets(t, "stranger", "", func(*Session) {}, func(ls *mockLocateService) {
  1221. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("stranger")).
  1222. Return(onlineUserInfoReply("stranger", 0), nil).Once()
  1223. })
  1224. assert.Equal(t, "online", got.State)
  1225. })
  1226. // An away message is absent from presence broadcasts, so it costs a query.
  1227. t.Run("an away buddy is looked up for their away message", func(t *testing.T) {
  1228. awayReply := wire.TLVUserInfo{ScreenName: "awaybud"}
  1229. awayReply.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
  1230. got := getTargets(t, "awaybud", "", func(sess *Session) {
  1231. buddyArrives(sess, awayReply)
  1232. }, func(ls *mockLocateService) {
  1233. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("awaybud")).
  1234. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  1235. TLVUserInfo: awayReply,
  1236. LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
  1237. wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "out to lunch"),
  1238. }},
  1239. }}, nil).Once()
  1240. })
  1241. assert.Equal(t, "away", got.State)
  1242. assert.Equal(t, "out to lunch", got.AwayMsg)
  1243. })
  1244. // Profile text is likewise only reachable through a locate reply.
  1245. t.Run("profileMsg forces a lookup even for a cached buddy", func(t *testing.T) {
  1246. got := getTargets(t, "mikekelly", "&profileMsg=1", func(sess *Session) {
  1247. buddyArrives(sess, onlineBuddy("Mike Kelly"))
  1248. }, func(ls *mockLocateService) {
  1249. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("mikekelly")).
  1250. Return(onlineUserInfoReply("Mike Kelly", 0), nil).Once()
  1251. })
  1252. assert.Equal(t, "online", got.State)
  1253. })
  1254. }
  1255. // presence/get?bl=1 walks the whole roster, so only an unavailable buddy may cost
  1256. // a locate query. Mockery fails the test on any other call.
  1257. func TestPresenceHandler_GetPresence_BuddyListQueriesOnlyAwayBuddies(t *testing.T) {
  1258. items := []wire.FeedbagItem{
  1259. {ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
  1260. {ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  1261. {ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "onlinebud"},
  1262. {ItemID: 102, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "awaybud"},
  1263. {ItemID: 103, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "neverseen"},
  1264. }
  1265. feedbagService := newMockFeedbagService(t)
  1266. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  1267. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
  1268. locateService := newMockLocateService(t)
  1269. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("awaybud")).
  1270. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  1271. TLVUserInfo: wire.TLVUserInfo{ScreenName: "awaybud"},
  1272. LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
  1273. wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "out to lunch"),
  1274. }},
  1275. }}, nil).Once()
  1276. oscarInstance := state.NewSession().AddInstance()
  1277. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  1278. sess, err := sessionMgr.GetSession(context.Background(), aimsid)
  1279. require.NoError(t, err)
  1280. sess.FeedbagLoader = feedbagServiceLoader(feedbagService, oscarInstance)
  1281. away := wire.TLVUserInfo{ScreenName: "awaybud"}
  1282. away.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
  1283. buddyArrives(sess, onlineBuddy("onlinebud"))
  1284. buddyArrives(sess, away)
  1285. handler := &PresenceHandler{
  1286. SessionManager: sessionMgr,
  1287. FeedbagService: feedbagService,
  1288. LocateService: locateService,
  1289. IconSource: newTestIconSource(t),
  1290. Logger: slog.Default(),
  1291. }
  1292. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
  1293. require.NoError(t, err)
  1294. rr := httptest.NewRecorder()
  1295. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  1296. require.Equal(t, http.StatusOK, rr.Code)
  1297. var got struct {
  1298. Response struct {
  1299. Data struct {
  1300. Groups []struct {
  1301. Buddies []struct {
  1302. AimID string `json:"aimId"`
  1303. State string `json:"state"`
  1304. AwayMsg string `json:"awayMsg"`
  1305. } `json:"buddies"`
  1306. } `json:"groups"`
  1307. } `json:"data"`
  1308. } `json:"response"`
  1309. }
  1310. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  1311. require.Len(t, got.Response.Data.Groups, 1)
  1312. states := map[string]string{}
  1313. awayMsgs := map[string]string{}
  1314. for _, b := range got.Response.Data.Groups[0].Buddies {
  1315. states[b.AimID] = b.State
  1316. awayMsgs[b.AimID] = b.AwayMsg
  1317. }
  1318. assert.Equal(t, "online", states["onlinebud"])
  1319. assert.Equal(t, "away", states["awaybud"])
  1320. // The away message is the one field a presence broadcast cannot carry.
  1321. assert.Equal(t, "out to lunch", awayMsgs["awaybud"])
  1322. // A buddy no arrival has been relayed for renders offline rather than
  1323. // triggering a lookup.
  1324. assert.Equal(t, "offline", states["neverseen"])
  1325. }
  1326. // presence/get?t= must not trust a cache hit it no longer holds. It branches on a
  1327. // lookup and then renders the record, so the record has to travel with the branch:
  1328. // looking it up a second time can miss after an eviction and report the user
  1329. // offline instead of falling back to the locate query.
  1330. func TestPresenceHandler_GetPresence_ForgottenTargetFallsBackToLocate(t *testing.T) {
  1331. feedbagService := newMockFeedbagService(t)
  1332. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  1333. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  1334. // The target is no longer watched, so only a live lookup knows their state.
  1335. locateService := newMockLocateService(t)
  1336. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("mikekelly")).
  1337. Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil).Once()
  1338. oscarInstance := state.NewSession().AddInstance()
  1339. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  1340. sess, err := sessionMgr.GetSession(context.Background(), aimsid)
  1341. require.NoError(t, err)
  1342. buddyArrives(sess, onlineBuddy("Mike Kelly"))
  1343. sess.forgetBuddyPresence(state.NewIdentScreenName("mikekelly"))
  1344. handler := &PresenceHandler{
  1345. SessionManager: sessionMgr,
  1346. FeedbagService: feedbagService,
  1347. LocateService: locateService,
  1348. IconSource: newTestIconSource(t),
  1349. Logger: slog.Default(),
  1350. }
  1351. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=mikekelly", nil)
  1352. require.NoError(t, err)
  1353. rr := httptest.NewRecorder()
  1354. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  1355. require.Equal(t, http.StatusOK, rr.Code)
  1356. var got struct {
  1357. Response struct {
  1358. Data struct {
  1359. Users []struct {
  1360. AimID string `json:"aimId"`
  1361. State string `json:"state"`
  1362. } `json:"users"`
  1363. } `json:"data"`
  1364. } `json:"response"`
  1365. }
  1366. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  1367. require.Len(t, got.Response.Data.Users, 1)
  1368. // The stale "online" snapshot must not survive the eviction.
  1369. assert.Equal(t, "offline", got.Response.Data.Users[0].State)
  1370. }
  1371. // An invisible user reads as offline, and an offline user publishes nothing.
  1372. func TestPresenceHandler_GetPresence_InvisibleUserPublishesNothing(t *testing.T) {
  1373. invisible := wire.TLVUserInfo{ScreenName: "sneaky"}
  1374. invisible.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusInvisible))
  1375. invisible.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
  1376. feedbagService := newMockFeedbagService(t)
  1377. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  1378. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  1379. locateService := newMockLocateService(t)
  1380. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("sneaky")).
  1381. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  1382. TLVUserInfo: invisible,
  1383. LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
  1384. wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "back later"),
  1385. }},
  1386. }}, nil).Once()
  1387. iconRetriever := newMockBuddyIconRetriever(t)
  1388. oscarInstance := state.NewSession().AddInstance()
  1389. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  1390. sess, err := sessionMgr.GetSession(context.Background(), aimsid)
  1391. require.NoError(t, err)
  1392. sess.BaseURL = "http://api.example.com"
  1393. handler := &PresenceHandler{
  1394. SessionManager: sessionMgr,
  1395. FeedbagService: feedbagService,
  1396. LocateService: locateService,
  1397. IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
  1398. Logger: slog.Default(),
  1399. }
  1400. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=sneaky", nil)
  1401. require.NoError(t, err)
  1402. rr := httptest.NewRecorder()
  1403. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  1404. require.Equal(t, http.StatusOK, rr.Code)
  1405. var got struct {
  1406. Response struct {
  1407. Data struct {
  1408. Users []struct {
  1409. State string `json:"state"`
  1410. BuddyIcon string `json:"buddyIcon"`
  1411. StatusMsg string `json:"statusMsg"`
  1412. AwayMsg string `json:"awayMsg"`
  1413. } `json:"users"`
  1414. } `json:"data"`
  1415. } `json:"response"`
  1416. }
  1417. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  1418. require.Len(t, got.Response.Data.Users, 1)
  1419. user := got.Response.Data.Users[0]
  1420. assert.Equal(t, "offline", user.State)
  1421. assert.Empty(t, user.BuddyIcon)
  1422. assert.Empty(t, user.StatusMsg)
  1423. assert.Empty(t, user.AwayMsg)
  1424. iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
  1425. }