presence_handler_test.go 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036
  1. package webapi
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "log/slog"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "testing"
  12. "github.com/stretchr/testify/assert"
  13. "github.com/stretchr/testify/mock"
  14. "github.com/stretchr/testify/require"
  15. "github.com/mk6i/open-oscar-server/state"
  16. "github.com/mk6i/open-oscar-server/wire"
  17. )
  18. // onlineUserInfoReply builds a locate UserInfoReply for an online user,
  19. // optionally marking them idle by the given number of minutes (0 = not idle).
  20. func onlineUserInfoReply(screenName string, idleMinutes uint16) wire.SNACMessage {
  21. info := wire.TLVUserInfo{ScreenName: screenName}
  22. if idleMinutes > 0 {
  23. info.Append(wire.NewTLVBE(wire.OServiceUserInfoIdleTime, idleMinutes))
  24. }
  25. return wire.SNACMessage{
  26. Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: info},
  27. }
  28. }
  29. // screenNameMatcher matches a UserInfoQuery request body by its target screen name.
  30. func screenNameMatcher(screenName string) any {
  31. return mock.MatchedBy(func(b wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool {
  32. return b.ScreenName == screenName
  33. })
  34. }
  35. func TestPresenceHandler_GetPresence(t *testing.T) {
  36. tests := []struct {
  37. name string
  38. queryParams string
  39. setupMocks func(*mockFeedbagService, *mockLocateService)
  40. expectedStatusCode int
  41. checkResponse func(*testing.T, string)
  42. }{
  43. {
  44. name: "Success_BuddyList",
  45. queryParams: "bl=1",
  46. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  47. // Return feedbag with a group and buddy
  48. fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  49. Return(wire.SNACMessage{
  50. Body: wire.SNAC_0x13_0x06_FeedbagReply{
  51. Items: []wire.FeedbagItem{
  52. {ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
  53. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
  54. },
  55. },
  56. }, nil)
  57. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("buddy1")).
  58. Return(onlineUserInfoReply("buddy1", 0), nil)
  59. },
  60. expectedStatusCode: http.StatusOK,
  61. checkResponse: func(t *testing.T, body string) {
  62. assert.Contains(t, body, `"statusCode":200`)
  63. assert.Contains(t, body, `"groups"`)
  64. assert.Contains(t, body, `"Friends"`)
  65. assert.Contains(t, body, `"buddy1"`)
  66. assert.Contains(t, body, `"online"`)
  67. },
  68. },
  69. {
  70. name: "Success_TargetUsers",
  71. queryParams: "t=user1,user2",
  72. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  73. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user1")).
  74. Return(onlineUserInfoReply("user1", 0), nil)
  75. // user2 is idle for 7 minutes.
  76. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user2")).
  77. Return(onlineUserInfoReply("user2", 7), nil)
  78. },
  79. expectedStatusCode: http.StatusOK,
  80. checkResponse: func(t *testing.T, body string) {
  81. assert.Contains(t, body, `"statusCode":200`)
  82. assert.Contains(t, body, `"users"`)
  83. assert.Contains(t, body, `"user1"`)
  84. assert.Contains(t, body, `"user2"`)
  85. assert.Contains(t, body, `"idle"`)
  86. },
  87. },
  88. {
  89. name: "Success_BlockedOrOfflineUser",
  90. queryParams: "t=blockeduser",
  91. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  92. // A blocked or offline user comes back as a locate error.
  93. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("blockeduser")).
  94. Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
  95. },
  96. expectedStatusCode: http.StatusOK,
  97. checkResponse: func(t *testing.T, body string) {
  98. assert.Contains(t, body, `"statusCode":200`)
  99. assert.Contains(t, body, `"blockeduser"`)
  100. assert.Contains(t, body, `"offline"`)
  101. },
  102. },
  103. {
  104. name: "Success_EmptyRequest",
  105. queryParams: "",
  106. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {},
  107. expectedStatusCode: http.StatusOK,
  108. checkResponse: func(t *testing.T, body string) {
  109. assert.Contains(t, body, `"statusCode":200`)
  110. },
  111. },
  112. {
  113. // A full search page: memberDir/search returns 20 profiles and the
  114. // client asks about every one, plus the keyword when it is a UIN.
  115. // This is the largest list a real client sends, and it must be served
  116. // whole — anything but a 200 costs the user the entire page.
  117. name: "Success_FullSearchPage",
  118. queryParams: "t=" + strings.Join(searchPageTargets(21), "&t="),
  119. setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
  120. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  121. RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
  122. return onlineUserInfoReply(body.ScreenName, 0), nil
  123. }).Times(21)
  124. },
  125. expectedStatusCode: http.StatusOK,
  126. checkResponse: func(t *testing.T, body string) {
  127. assert.Contains(t, body, `"statusCode":200`)
  128. assert.Contains(t, body, `"user0"`)
  129. assert.Contains(t, body, `"user20"`)
  130. },
  131. },
  132. }
  133. for _, tt := range tests {
  134. t.Run(tt.name, func(t *testing.T) {
  135. feedbagService := newMockFeedbagService(t)
  136. locateService := newMockLocateService(t)
  137. oscarInstance := state.NewSession().AddInstance()
  138. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  139. handler := &PresenceHandler{
  140. SessionManager: sessionMgr,
  141. FeedbagService: feedbagService,
  142. LocateService: locateService,
  143. Logger: slog.Default(),
  144. }
  145. tt.setupMocks(feedbagService, locateService)
  146. // Presence payloads carry the viewer's alias, so GetPresence reads the
  147. // feedbag. Registered last so a case's own Query stub takes precedence.
  148. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  149. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  150. reqURL := "/presence/get?aimsid=" + aimsid
  151. if tt.queryParams != "" {
  152. reqURL += "&" + tt.queryParams
  153. }
  154. req, err := http.NewRequest("GET", reqURL, nil)
  155. assert.NoError(t, err)
  156. rr := httptest.NewRecorder()
  157. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  158. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  159. if tt.checkResponse != nil {
  160. responseBody := strings.TrimSpace(rr.Body.String())
  161. tt.checkResponse(t, responseBody)
  162. }
  163. })
  164. }
  165. }
  166. // TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly verifies that
  167. // the icon is published only for an online, non-blocking user, and that an
  168. // offline or blocking user is never even looked up — so neither their icon nor
  169. // its hash leaks to a caller they are invisible to.
  170. func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testing.T) {
  171. ctx := context.Background()
  172. feedbagService := newMockFeedbagService(t)
  173. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  174. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  175. locateService := newMockLocateService(t)
  176. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("onlineuser")).
  177. Return(onlineUserInfoReply("onlineuser", 0), nil)
  178. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("offlineuser")).
  179. Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
  180. iconRetriever := newMockBuddyIconRetriever(t)
  181. iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlineuser")).
  182. Return(bartID([]byte{0xab, 0xcd}), nil).Once()
  183. oscarInstance := state.NewSession().AddInstance()
  184. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  185. sess, err := sessionMgr.GetSession(ctx, aimsid)
  186. require.NoError(t, err)
  187. sess.BaseURL = "http://api.example.com"
  188. handler := &PresenceHandler{
  189. SessionManager: sessionMgr,
  190. FeedbagService: feedbagService,
  191. LocateService: locateService,
  192. IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
  193. Logger: slog.Default(),
  194. }
  195. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=onlineuser,offlineuser", nil)
  196. require.NoError(t, err)
  197. rr := httptest.NewRecorder()
  198. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  199. require.Equal(t, http.StatusOK, rr.Code)
  200. var got struct {
  201. Response struct {
  202. Data struct {
  203. Users []struct {
  204. AimID string `json:"aimId"`
  205. State string `json:"state"`
  206. BuddyIcon string `json:"buddyIcon"`
  207. } `json:"users"`
  208. } `json:"data"`
  209. } `json:"response"`
  210. }
  211. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  212. icons := map[string]string{}
  213. states := map[string]string{}
  214. for _, u := range got.Response.Data.Users {
  215. icons[u.AimID] = u.BuddyIcon
  216. states[u.AimID] = u.State
  217. }
  218. assert.Equal(t, "online", states["onlineuser"])
  219. assert.Equal(t,
  220. "http://api.example.com/expressions/get?t=onlineuser&type=buddyIcon&bartId=abcd",
  221. icons["onlineuser"])
  222. assert.Equal(t, "offline", states["offlineuser"])
  223. assert.Empty(t, icons["offlineuser"])
  224. iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("offlineuser"))
  225. }
  226. // TestPresenceHandler_GetPresence_BuddyListGrouping verifies that bl=1 places
  227. // each buddy under its own group using realistic feedbag data, where group rows
  228. // carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
  229. // GroupIDs. This is the shape the OSCAR feedbag actually stores.
  230. func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
  231. feedbagService := newMockFeedbagService(t)
  232. locateService := newMockLocateService(t)
  233. items := []wire.FeedbagItem{
  234. // Root order group: ItemID 0, GroupID 0, empty name — not a real buddy group.
  235. {ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
  236. // Named groups: ItemID 0, distinct nonzero GroupIDs.
  237. {ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  238. {ItemID: 0, GroupID: 20, ClassID: wire.FeedbagClassIdGroup, Name: "Work"},
  239. // Buddies reference their group's GroupID.
  240. {ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
  241. {ItemID: 201, GroupID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
  242. }
  243. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  244. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
  245. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("alice")).
  246. Return(onlineUserInfoReply("alice", 0), nil)
  247. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("bob")).
  248. Return(onlineUserInfoReply("bob", 0), nil)
  249. oscarInstance := state.NewSession().AddInstance()
  250. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  251. handler := &PresenceHandler{
  252. SessionManager: sessionMgr,
  253. FeedbagService: feedbagService,
  254. LocateService: locateService,
  255. Logger: slog.Default(),
  256. }
  257. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
  258. assert.NoError(t, err)
  259. rr := httptest.NewRecorder()
  260. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  261. assert.Equal(t, http.StatusOK, rr.Code)
  262. var parsed struct {
  263. Response struct {
  264. Data struct {
  265. Groups []struct {
  266. Name string `json:"name"`
  267. Buddies []struct {
  268. AimID string `json:"aimId"`
  269. } `json:"buddies"`
  270. } `json:"groups"`
  271. } `json:"data"`
  272. } `json:"response"`
  273. }
  274. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &parsed))
  275. // Build name -> set of buddy aimIds.
  276. byGroup := map[string][]string{}
  277. for _, g := range parsed.Response.Data.Groups {
  278. for _, b := range g.Buddies {
  279. byGroup[g.Name] = append(byGroup[g.Name], b.AimID)
  280. }
  281. }
  282. // Exactly the two named groups appear; the root group is excluded.
  283. assert.Len(t, parsed.Response.Data.Groups, 2)
  284. assert.Equal(t, []string{"alice"}, byGroup["Friends"])
  285. assert.Equal(t, []string{"bob"}, byGroup["Work"])
  286. }
  287. func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
  288. handler := &PresenceHandler{
  289. SessionManager: NewSessionManager(),
  290. Logger: slog.Default(),
  291. }
  292. req, err := http.NewRequest("GET", "/presence/get", nil)
  293. assert.NoError(t, err)
  294. rr := httptest.NewRecorder()
  295. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  296. assert.Equal(t, http.StatusBadRequest, rr.Code)
  297. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  298. }
  299. func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
  300. handler := &PresenceHandler{
  301. SessionManager: NewSessionManager(),
  302. Logger: slog.Default(),
  303. }
  304. req, err := http.NewRequest("GET", "/presence/get?aimsid=nonexistent", nil)
  305. assert.NoError(t, err)
  306. rr := httptest.NewRecorder()
  307. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  308. assert.Equal(t, http.StatusUnauthorized, rr.Code)
  309. assert.Contains(t, rr.Body.String(), "invalid or expired session")
  310. }
  311. func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
  312. handler := &PresenceHandler{
  313. SessionManager: NewSessionManager(),
  314. Logger: slog.Default(),
  315. }
  316. req, err := http.NewRequest("GET", "/presence/setState", nil)
  317. assert.NoError(t, err)
  318. rr := httptest.NewRecorder()
  319. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  320. assert.Equal(t, http.StatusBadRequest, rr.Code)
  321. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  322. }
  323. func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
  324. oscarInstance := state.NewSession().AddInstance()
  325. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  326. handler := &PresenceHandler{
  327. SessionManager: sessionMgr,
  328. Logger: slog.Default(),
  329. }
  330. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=bogus", nil)
  331. assert.NoError(t, err)
  332. rr := httptest.NewRecorder()
  333. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  334. assert.Equal(t, http.StatusBadRequest, rr.Code)
  335. assert.Contains(t, rr.Body.String(), "invalid state parameter")
  336. }
  337. func TestPresenceHandler_SetState_EmitsMyInfoEvent(t *testing.T) {
  338. // The AIM client re-renders its own status badge only from "myInfo" events,
  339. // so setState must queue one on the user's own session for the change to be
  340. // visible in their UI.
  341. oscarInstance := state.NewSession().AddInstance()
  342. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  343. broadcaster := newMockBuddyBroadcaster(t)
  344. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  345. handler := &PresenceHandler{
  346. SessionManager: sessionMgr,
  347. BuddyBroadcaster: broadcaster,
  348. Logger: slog.Default(),
  349. }
  350. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away&awayMsg=brb", nil)
  351. assert.NoError(t, err)
  352. rr := httptest.NewRecorder()
  353. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  354. assert.Equal(t, http.StatusOK, rr.Code)
  355. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  356. assert.NoError(t, err)
  357. myInfo := queuedMyInfo(session)
  358. assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
  359. assert.Equal(t, "away", myInfo.State)
  360. assert.Equal(t, "brb", myInfo.AwayMsg)
  361. assert.Equal(t, "testuser", myInfo.AimID)
  362. }
  363. func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
  364. // The client shallow-merges myInfo onto the shared user object, so aimId must
  365. // be the normalized id while displayId and friendly keep the user's own
  366. // casing and spacing.
  367. oscarInstance := state.NewSession().AddInstance()
  368. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("Mike Kelly", oscarInstance)
  369. broadcaster := newMockBuddyBroadcaster(t)
  370. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  371. handler := &PresenceHandler{
  372. SessionManager: sessionMgr,
  373. BuddyBroadcaster: broadcaster,
  374. Logger: slog.Default(),
  375. }
  376. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away", nil)
  377. assert.NoError(t, err)
  378. rr := httptest.NewRecorder()
  379. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  380. assert.Equal(t, http.StatusOK, rr.Code)
  381. // The setState response body carries the same identity fields.
  382. var resp struct {
  383. Response struct {
  384. Data map[string]any `json:"data"`
  385. } `json:"response"`
  386. }
  387. assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  388. assert.Equal(t, "mikekelly", resp.Response.Data["aimId"])
  389. assert.Equal(t, "Mike Kelly", resp.Response.Data["displayId"])
  390. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  391. assert.NoError(t, err)
  392. myInfo := queuedMyInfo(session)
  393. require.NotNil(t, myInfo, "expected a myInfo event to be queued")
  394. assert.Equal(t, "mikekelly", myInfo.AimID)
  395. assert.Equal(t, "Mike Kelly", myInfo.DisplayID)
  396. assert.Equal(t, "Mike Kelly", myInfo.Friendly)
  397. }
  398. func TestPresenceHandler_Icon(t *testing.T) {
  399. tests := []struct {
  400. name string
  401. queryParams string
  402. expectedStatusCode int
  403. checkRedirect func(*testing.T, *httptest.ResponseRecorder)
  404. }{
  405. {
  406. name: "Redirect_OfflineUser",
  407. // No aimsid, so there is no OSCAR session to query on behalf of and
  408. // the target resolves to offline.
  409. queryParams: "name=offlineuser",
  410. expectedStatusCode: http.StatusFound,
  411. checkRedirect: func(t *testing.T, rr *httptest.ResponseRecorder) {
  412. location := rr.Header().Get("Location")
  413. assert.Contains(t, location, "offline")
  414. },
  415. },
  416. {
  417. name: "Error_MissingName",
  418. queryParams: "",
  419. expectedStatusCode: http.StatusBadRequest,
  420. },
  421. }
  422. for _, tt := range tests {
  423. t.Run(tt.name, func(t *testing.T) {
  424. handler := &PresenceHandler{
  425. SessionManager: NewSessionManager(),
  426. LocateService: newMockLocateService(t),
  427. Logger: slog.Default(),
  428. }
  429. reqURL := "/presence/icon"
  430. if tt.queryParams != "" {
  431. reqURL += "?" + tt.queryParams
  432. }
  433. req, err := http.NewRequest("GET", reqURL, nil)
  434. assert.NoError(t, err)
  435. rr := httptest.NewRecorder()
  436. handler.Icon(rr, req)
  437. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  438. if tt.checkRedirect != nil {
  439. tt.checkRedirect(t, rr)
  440. }
  441. })
  442. }
  443. }
  444. func TestPresenceHandler_SetProfile(t *testing.T) {
  445. oscarInstance := state.NewSession().AddInstance()
  446. tests := []struct {
  447. name string
  448. queryParams string
  449. setupMocks func(*mockLocateService)
  450. expectedStatusCode int
  451. checkResponse func(*testing.T, string)
  452. }{
  453. {
  454. name: "Success_SetProfile",
  455. queryParams: "profile=Hello+World",
  456. setupMocks: func(ls *mockLocateService) {
  457. ls.EXPECT().SetInfo(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
  458. },
  459. expectedStatusCode: http.StatusOK,
  460. checkResponse: func(t *testing.T, body string) {
  461. assert.Contains(t, body, `"statusCode":200`)
  462. },
  463. },
  464. {
  465. name: "Error_ProfileTooLarge",
  466. queryParams: "profile=" + strings.Repeat("x", 4097),
  467. setupMocks: func(ls *mockLocateService) {},
  468. expectedStatusCode: http.StatusBadRequest,
  469. checkResponse: func(t *testing.T, body string) {
  470. assert.Contains(t, body, "profile too large")
  471. },
  472. },
  473. }
  474. for _, tt := range tests {
  475. t.Run(tt.name, func(t *testing.T) {
  476. locateService := newMockLocateService(t)
  477. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  478. handler := &PresenceHandler{
  479. SessionManager: sessionMgr,
  480. LocateService: locateService,
  481. Logger: slog.Default(),
  482. }
  483. tt.setupMocks(locateService)
  484. reqURL := "/presence/setProfile?aimsid=" + aimsid + "&" + tt.queryParams
  485. req, err := http.NewRequest("GET", reqURL, nil)
  486. assert.NoError(t, err)
  487. rr := httptest.NewRecorder()
  488. requireSession(handler.SessionManager, handler.SetProfile).ServeHTTP(rr, req)
  489. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  490. if tt.checkResponse != nil {
  491. responseBody := strings.TrimSpace(rr.Body.String())
  492. tt.checkResponse(t, responseBody)
  493. }
  494. })
  495. }
  496. }
  497. func TestPresenceHandler_GetProfile(t *testing.T) {
  498. locateService := newMockLocateService(t)
  499. oscarInstance := state.NewSession().AddInstance()
  500. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  501. handler := &PresenceHandler{
  502. SessionManager: sessionMgr,
  503. LocateService: locateService,
  504. Logger: slog.Default(),
  505. }
  506. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
  507. Return(wire.SNACMessage{
  508. Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  509. LocateInfo: wire.TLVRestBlock{
  510. TLVList: wire.TLVList{
  511. wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "My profile"),
  512. },
  513. },
  514. },
  515. }, nil)
  516. req, err := http.NewRequest("GET", "/presence/getProfile?aimsid="+aimsid, nil)
  517. assert.NoError(t, err)
  518. rr := httptest.NewRecorder()
  519. requireSession(handler.SessionManager, handler.GetProfile).ServeHTTP(rr, req)
  520. assert.Equal(t, http.StatusOK, rr.Code)
  521. body := rr.Body.String()
  522. assert.Contains(t, body, `"statusCode":200`)
  523. assert.Contains(t, body, `"My profile"`)
  524. assert.Contains(t, body, `"testuser"`)
  525. }
  526. // queuedMyInfo returns the myInfo event the session has queued, if any.
  527. // queuedMyInfo returns the myInfo event the session has queued, if any.
  528. func queuedMyInfo(session *Session) *MyInfo {
  529. var myInfo *MyInfo
  530. for _, event := range session.EventQueue.GetAllEvents() {
  531. if event.Type == "myInfo" {
  532. myInfo, _ = event.Data.(*MyInfo)
  533. }
  534. }
  535. return myInfo
  536. }
  537. func TestPresenceHandler_SetState_Occupied(t *testing.T) {
  538. // ICQ's Busy, which is a selectable connect state and must be accepted.
  539. oscarInstance := state.NewSession().AddInstance()
  540. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  541. broadcaster := newMockBuddyBroadcaster(t)
  542. broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
  543. handler := &PresenceHandler{
  544. SessionManager: sessionMgr,
  545. BuddyBroadcaster: broadcaster,
  546. Logger: slog.Default(),
  547. }
  548. req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&view=occupied&away=", nil)
  549. assert.NoError(t, err)
  550. rr := httptest.NewRecorder()
  551. requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
  552. assert.Equal(t, http.StatusOK, rr.Code)
  553. assert.Equal(t, wire.OServiceUserStatusBusy, oscarInstance.UserStatusBitmask())
  554. // The state must survive the round trip: a later myInfo push reads it back
  555. // through currentWebState, and reporting "away" or "online" there would undo
  556. // the change in the user's own UI.
  557. assert.Equal(t, "occupied", currentWebState(oscarInstance))
  558. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  559. assert.NoError(t, err)
  560. myInfo := queuedMyInfo(session)
  561. assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
  562. assert.Equal(t, "occupied", myInfo.State)
  563. }
  564. func TestPresenceHandler_GetPresence_MdirAttachesProfile(t *testing.T) {
  565. // Search results are populated from this call, and a user carrying no nested
  566. // "profile" object is dropped by clients, so a found user would not render.
  567. ctx := context.Background()
  568. dirReply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  569. dirReply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Bob"))
  570. dirReply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Smith"))
  571. dirReply.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
  572. feedbagService := newMockFeedbagService(t)
  573. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  574. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  575. locateService := newMockLocateService(t)
  576. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  577. Return(onlineUserInfoReply("founduser", 0), nil)
  578. locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  579. Return(wire.SNACMessage{Body: dirReply}, nil)
  580. oscarInstance := state.NewSession().AddInstance()
  581. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  582. _, err := sessionMgr.GetSession(ctx, aimsid)
  583. require.NoError(t, err)
  584. handler := &PresenceHandler{
  585. SessionManager: sessionMgr,
  586. FeedbagService: feedbagService,
  587. LocateService: locateService,
  588. Logger: slog.Default(),
  589. }
  590. req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=founduser", nil)
  591. require.NoError(t, err)
  592. rr := httptest.NewRecorder()
  593. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  594. require.Equal(t, http.StatusOK, rr.Code)
  595. var got struct {
  596. Response struct {
  597. Data struct {
  598. Users []struct {
  599. AimID string `json:"aimId"`
  600. Profile *BuddyProfileInfo `json:"profile"`
  601. } `json:"users"`
  602. } `json:"data"`
  603. } `json:"response"`
  604. }
  605. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
  606. require.Len(t, got.Response.Data.Users, 1)
  607. profile := got.Response.Data.Users[0].Profile
  608. require.NotNil(t, profile, "mdir=1 must carry a profile object")
  609. assert.Equal(t, "Bob", profile.FirstName)
  610. assert.Equal(t, "Smith", profile.LastName)
  611. require.Len(t, profile.HomeAddress, 1)
  612. assert.Equal(t, "Reno", profile.HomeAddress[0].City)
  613. }
  614. func TestPresenceHandler_GetPresence_MdirProfileIsEmptyNotAbsent(t *testing.T) {
  615. // A user with no directory record must still render, or a freshly created
  616. // account can never be found.
  617. ctx := context.Background()
  618. feedbagService := newMockFeedbagService(t)
  619. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  620. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  621. locateService := newMockLocateService(t)
  622. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  623. Return(onlineUserInfoReply("blankuser", 0), nil)
  624. locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  625. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil)
  626. oscarInstance := state.NewSession().AddInstance()
  627. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  628. _, err := sessionMgr.GetSession(ctx, aimsid)
  629. require.NoError(t, err)
  630. handler := &PresenceHandler{
  631. SessionManager: sessionMgr,
  632. FeedbagService: feedbagService,
  633. LocateService: locateService,
  634. Logger: slog.Default(),
  635. }
  636. req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=blankuser", nil)
  637. rr := httptest.NewRecorder()
  638. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  639. // The key must be present and non-null, which is what the client tests for.
  640. assert.Contains(t, rr.Body.String(), `"profile":{}`)
  641. }
  642. func TestPresenceHandler_GetPresence_NoMdirOmitsProfile(t *testing.T) {
  643. // Without mdir the directory is not consulted at all — the mock asserts that by
  644. // having no DirInfo expectation.
  645. ctx := context.Background()
  646. feedbagService := newMockFeedbagService(t)
  647. feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  648. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
  649. locateService := newMockLocateService(t)
  650. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  651. Return(onlineUserInfoReply("someuser", 0), nil)
  652. oscarInstance := state.NewSession().AddInstance()
  653. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  654. _, err := sessionMgr.GetSession(ctx, aimsid)
  655. require.NoError(t, err)
  656. handler := &PresenceHandler{
  657. SessionManager: sessionMgr,
  658. FeedbagService: feedbagService,
  659. LocateService: locateService,
  660. Logger: slog.Default(),
  661. }
  662. req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=someuser", nil)
  663. rr := httptest.NewRecorder()
  664. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  665. assert.NotContains(t, rr.Body.String(), `"profile"`)
  666. }
  667. func TestPresenceHandler_GetPresence_EmptyQueryRendersBothArrays(t *testing.T) {
  668. ctx := context.Background()
  669. // No expectations on either service: naming no list must cost no lookups.
  670. feedbagService := newMockFeedbagService(t)
  671. locateService := newMockLocateService(t)
  672. oscarInstance := state.NewSession().AddInstance()
  673. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  674. _, err := sessionMgr.GetSession(ctx, aimsid)
  675. require.NoError(t, err)
  676. handler := &PresenceHandler{
  677. SessionManager: sessionMgr,
  678. FeedbagService: feedbagService,
  679. LocateService: locateService,
  680. Logger: slog.Default(),
  681. }
  682. req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&f=json&mdir=1", nil)
  683. rr := httptest.NewRecorder()
  684. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  685. assert.Equal(t, http.StatusOK, rr.Code)
  686. assert.Contains(t, rr.Body.String(), `"users":[]`)
  687. assert.Contains(t, rr.Body.String(), `"groups":[]`)
  688. }
  689. func TestPresenceHandler_GetPresence_TruncatesOversizedTargetList(t *testing.T) {
  690. ctx := context.Background()
  691. feedbagService := newMockFeedbagService(t)
  692. var queried int
  693. locateService := newMockLocateService(t)
  694. locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  695. RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
  696. queried++
  697. return onlineUserInfoReply(body.ScreenName, 0), nil
  698. })
  699. oscarInstance := state.NewSession().AddInstance()
  700. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  701. _, err := sessionMgr.GetSession(ctx, aimsid)
  702. require.NoError(t, err)
  703. handler := &PresenceHandler{
  704. SessionManager: sessionMgr,
  705. FeedbagService: feedbagService,
  706. LocateService: locateService,
  707. Logger: slog.Default(),
  708. }
  709. query := "/presence/get?aimsid=" + aimsid + "&f=json"
  710. for i := range maxPresenceTargets + 8 {
  711. query += fmt.Sprintf("&t=user%d", i)
  712. }
  713. req, _ := http.NewRequest("GET", query, nil)
  714. rr := httptest.NewRecorder()
  715. requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
  716. assert.Equal(t, http.StatusOK, rr.Code)
  717. assert.Contains(t, rr.Body.String(), `"statusCode":200`)
  718. assert.Equal(t, maxPresenceTargets, queried, "the list is cut to the cap, not refused")
  719. // A full search page has to fit: 20 profiles plus the searched-for UIN.
  720. assert.GreaterOrEqual(t, maxPresenceTargets, 21)
  721. }
  722. // searchPageTargets builds n distinct screen names, standing in for the hits of a
  723. // member-directory search page.
  724. func searchPageTargets(n int) []string {
  725. names := make([]string, 0, n)
  726. for i := range n {
  727. names = append(names, fmt.Sprintf("user%d", i))
  728. }
  729. return names
  730. }
  731. // setStatusCaps drives setStatus and returns the capability list that reached
  732. // LocateService.SetInfo, or nil when SetInfo was never called.
  733. func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string) ([][16]byte, *Session, int) {
  734. t.Helper()
  735. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
  736. var gotCaps [][16]byte
  737. var called bool
  738. locate := newMockLocateService(t)
  739. locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).
  740. Run(func(_ context.Context, _ *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) {
  741. called = true
  742. b, ok := inBody.Bytes(wire.LocateTLVTagsInfoCapabilities)
  743. require.True(t, ok, "setStatus must always send a capabilities TLV")
  744. require.Zero(t, len(b)%16)
  745. gotCaps = userInfoCaps(wire.TLVUserInfo{
  746. TLVBlock: wire.TLVBlock{
  747. TLVList: wire.TLVList{wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, b)},
  748. },
  749. })
  750. // Apply it, as the real service would, so a follow-up call sees it.
  751. instance.SetCaps(gotCaps)
  752. }).Return(nil).Maybe()
  753. handler := &PresenceHandler{
  754. SessionManager: sessionMgr,
  755. LocateService: locate,
  756. Logger: slog.Default(),
  757. }
  758. req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+query, nil)
  759. require.NoError(t, err)
  760. rr := httptest.NewRecorder()
  761. requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
  762. session, err := sessionMgr.GetSession(context.Background(), aimsid)
  763. require.NoError(t, err)
  764. if !called {
  765. return nil, session, rr.Code
  766. }
  767. return gotCaps, session, rr.Code
  768. }
  769. func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
  770. tests := []struct {
  771. name string
  772. // query is appended to the request, after aimsid.
  773. query string
  774. // wantCaps is the capability list that must reach SetInfo. A nil value
  775. // means SetInfo must not be called at all.
  776. wantCaps [][16]byte
  777. // wantMoodIcon is the id parameter expected on the queued myInfo, or ""
  778. // for no mood.
  779. wantMoodID string
  780. // wantCode defaults to 200.
  781. wantCode int
  782. }{
  783. {
  784. name: "a known mood is advertised as its capability",
  785. query: "&mood=0icqmood6",
  786. wantCaps: [][16]byte{wire.CapXStatusPlate},
  787. wantMoodID: "0icqmood6",
  788. },
  789. {
  790. name: "a mood with only a placeholder capability still resolves",
  791. query: "&mood=0icqmood13",
  792. wantCaps: [][16]byte{wire.CapMoodHavingFun},
  793. wantMoodID: "0icqmood13",
  794. },
  795. {
  796. // The client sends mood= alongside every plain state change, so this
  797. // is the path back to a moodless online/away/invisible.
  798. name: "an empty mood clears the capability",
  799. query: "&mood=",
  800. wantCaps: [][16]byte{},
  801. },
  802. {
  803. // A token the server cannot map is a client bug, not a reset: the
  804. // mood the user is actually showing is left alone.
  805. name: "an unrecognized mood is rejected",
  806. query: "&mood=0icqmood999",
  807. wantCode: http.StatusBadRequest,
  808. },
  809. {
  810. // A status-message-only update must leave the mood alone.
  811. name: "an absent mood parameter leaves capabilities untouched",
  812. query: "&statusMsg=hello",
  813. },
  814. }
  815. for _, tt := range tests {
  816. t.Run(tt.name, func(t *testing.T) {
  817. instance := state.NewSession().AddInstance()
  818. gotCaps, session, code := setStatusCaps(t, instance, tt.query)
  819. if tt.wantCode != 0 {
  820. assert.Equal(t, tt.wantCode, code)
  821. assert.Nil(t, gotCaps, "a rejected mood must not reach SetInfo")
  822. return
  823. }
  824. assert.Equal(t, http.StatusOK, code)
  825. assert.Equal(t, tt.wantCaps, gotCaps)
  826. myInfo := queuedMyInfo(session)
  827. require.NotNil(t, myInfo, "setStatus must queue a myInfo event")
  828. if tt.wantMoodID == "" {
  829. assert.Empty(t, myInfo.MoodIcon)
  830. return
  831. }
  832. assert.Equal(t, "/mood?id="+wire.MoodIconID(tt.wantMoodID), myInfo.MoodIcon)
  833. })
  834. }
  835. }
  836. func TestPresenceHandler_SetStatus_MoodReplacesRatherThanAccumulates(t *testing.T) {
  837. instance := state.NewSession().AddInstance()
  838. _, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
  839. assert.Equal(t, http.StatusOK, code)
  840. gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood4")
  841. assert.Equal(t, http.StatusOK, code)
  842. assert.Equal(t, [][16]byte{wire.CapXStatusBeer}, gotCaps, "the previous mood must be dropped")
  843. }
  844. func TestPresenceHandler_SetStatus_PreservesNonMoodCaps(t *testing.T) {
  845. // The capability list is rewritten wholesale, so anything the instance
  846. // advertises that is not a mood has to be carried over.
  847. instance := state.NewSession().AddInstance()
  848. instance.SetCaps([][16]byte{wire.CapChat, wire.CapXStatusBeer})
  849. gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
  850. assert.Equal(t, http.StatusOK, code)
  851. assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusPlate}, gotCaps)
  852. gotCaps, _, code = setStatusCaps(t, instance, "&mood=")
  853. assert.Equal(t, http.StatusOK, code)
  854. assert.Equal(t, [][16]byte{wire.CapChat}, gotCaps, "clearing a mood must keep the other caps")
  855. }
  856. func TestPresenceHandler_SetStatus_SetInfoError(t *testing.T) {
  857. instance := state.NewSession().AddInstance()
  858. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
  859. locate := newMockLocateService(t)
  860. locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(io.EOF)
  861. handler := &PresenceHandler{
  862. SessionManager: sessionMgr,
  863. LocateService: locate,
  864. Logger: slog.Default(),
  865. }
  866. req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&mood=0icqmood6", nil)
  867. require.NoError(t, err)
  868. rr := httptest.NewRecorder()
  869. requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
  870. assert.Equal(t, http.StatusInternalServerError, rr.Code)
  871. }