4
0

presence_handler_test.go 66 KB

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