4
0

presence_handler_test.go 65 KB

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