buddy_list_manager_test.go 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  1. package webapi
  2. import (
  3. "context"
  4. "errors"
  5. "log/slog"
  6. "testing"
  7. "github.com/stretchr/testify/assert"
  8. "github.com/stretchr/testify/mock"
  9. "github.com/stretchr/testify/require"
  10. "github.com/mk6i/open-oscar-server/state"
  11. "github.com/mk6i/open-oscar-server/wire"
  12. )
  13. func offlineWebAPIBuddy(aimID, displayID string) BuddyInfo {
  14. return BuddyInfo{
  15. AimID: aimID,
  16. DisplayID: displayID,
  17. State: "offline",
  18. UserType: "aim",
  19. Bot: false,
  20. }
  21. }
  22. // withAlias sets the viewer's private name for a buddy. It travels in friendly, not
  23. // displayId, which keeps carrying the buddy's own screen name.
  24. func withAlias(b BuddyInfo, alias string) BuddyInfo {
  25. b.Friendly = alias
  26. return b
  27. }
  28. func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
  29. ctx := context.Background()
  30. owner := state.NewIdentScreenName("listowner")
  31. tests := []struct {
  32. name string
  33. fb []wire.FeedbagItem
  34. fbErr error
  35. want []BuddyGroup
  36. wantErr string
  37. }{
  38. {
  39. name: "retrieve feedbag error",
  40. fbErr: errors.New("db unavailable"),
  41. wantErr: "failed to retrieve feedbag",
  42. },
  43. {
  44. name: "root group missing order attribute yields no groups",
  45. fb: []wire.FeedbagItem{
  46. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, TLVLBlock: wire.TLVLBlock{}},
  47. },
  48. want: nil,
  49. },
  50. {
  51. name: "empty buddylist yields no groups",
  52. fb: []wire.FeedbagItem{
  53. {
  54. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  55. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{})}},
  56. },
  57. },
  58. want: nil,
  59. },
  60. {
  61. name: "single group with buddies",
  62. fb: []wire.FeedbagItem{
  63. {
  64. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  65. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  66. },
  67. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  68. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2})}}},
  69. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user1", TLVLBlock: wire.TLVLBlock{}},
  70. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "user2", TLVLBlock: wire.TLVLBlock{}},
  71. },
  72. want: []BuddyGroup{
  73. {
  74. Name: "Buddies",
  75. ID: 100,
  76. Buddies: []BuddyInfo{
  77. offlineWebAPIBuddy("user1", "user1"),
  78. offlineWebAPIBuddy("user2", "user2"),
  79. },
  80. },
  81. },
  82. },
  83. {
  84. name: "deny permit and pdinfo items do not produce groups",
  85. fb: []wire.FeedbagItem{
  86. {
  87. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  88. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{})}},
  89. },
  90. {ClassID: wire.FeedbagClassIDDeny, Name: "blockeduser"},
  91. {ClassID: wire.FeedbagClassIDPermit, Name: "allowuser"},
  92. {
  93. ClassID: wire.FeedbagClassIdPdinfo,
  94. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(3))}},
  95. },
  96. },
  97. want: nil,
  98. },
  99. {
  100. name: "buddy with alias",
  101. fb: []wire.FeedbagItem{
  102. {
  103. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  104. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  105. },
  106. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  107. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  108. {
  109. ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "bob",
  110. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, "Bob Smith")}},
  111. },
  112. },
  113. want: []BuddyGroup{
  114. {
  115. Name: "Buddies",
  116. ID: 100,
  117. // The buddy is offline, so no locate reply supplies a display
  118. // name and displayId falls back to the normalized feedbag name.
  119. Buddies: []BuddyInfo{withAlias(offlineWebAPIBuddy("bob", "bob"), "Bob Smith")},
  120. },
  121. },
  122. },
  123. {
  124. name: "unnormalized feedbag buddy name still yields a normalized aimId",
  125. fb: []wire.FeedbagItem{
  126. {
  127. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  128. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  129. },
  130. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  131. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  132. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "Mike Kelly"},
  133. },
  134. want: []BuddyGroup{
  135. {
  136. Name: "Buddies",
  137. ID: 100,
  138. Buddies: []BuddyInfo{offlineWebAPIBuddy("mikekelly", "Mike Kelly")},
  139. },
  140. },
  141. },
  142. {
  143. name: "uin buddy is tagged icq",
  144. fb: []wire.FeedbagItem{
  145. {
  146. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  147. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  148. },
  149. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  150. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  151. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "100003"},
  152. },
  153. want: []BuddyGroup{
  154. {
  155. Name: "Buddies",
  156. ID: 100,
  157. Buddies: []BuddyInfo{{
  158. AimID: "100003",
  159. DisplayID: "100003",
  160. State: "offline",
  161. UserType: "icq",
  162. Service: "icq",
  163. }},
  164. },
  165. },
  166. },
  167. {
  168. name: "buddy with note still listed note not exposed in WebAPI",
  169. fb: []wire.FeedbagItem{
  170. {
  171. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  172. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  173. },
  174. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  175. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  176. {
  177. ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "alice",
  178. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesNote, "Friend from work")}},
  179. },
  180. },
  181. want: []BuddyGroup{
  182. {
  183. Name: "Buddies",
  184. ID: 100,
  185. Buddies: []BuddyInfo{offlineWebAPIBuddy("alice", "alice")},
  186. },
  187. },
  188. },
  189. {
  190. name: "multiple groups in root order",
  191. fb: []wire.FeedbagItem{
  192. {
  193. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  194. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100, 200})}},
  195. },
  196. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  197. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  198. {Name: "Family", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  199. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2})}}},
  200. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "friend1", TLVLBlock: wire.TLVLBlock{}},
  201. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 200, Name: "mom", TLVLBlock: wire.TLVLBlock{}},
  202. },
  203. want: []BuddyGroup{
  204. {
  205. Name: "Buddies",
  206. ID: 100,
  207. Buddies: []BuddyInfo{offlineWebAPIBuddy("friend1", "friend1")},
  208. },
  209. {
  210. Name: "Family",
  211. ID: 200,
  212. Buddies: []BuddyInfo{offlineWebAPIBuddy("mom", "mom")},
  213. },
  214. },
  215. },
  216. {
  217. name: "buddy order follows group order TLV not feedbag slice order",
  218. fb: []wire.FeedbagItem{
  219. {
  220. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  221. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  222. },
  223. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  224. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2, 1})}}},
  225. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "firstInSlice", TLVLBlock: wire.TLVLBlock{}},
  226. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "secondInSlice", TLVLBlock: wire.TLVLBlock{}},
  227. },
  228. want: []BuddyGroup{
  229. {
  230. Name: "Buddies",
  231. ID: 100,
  232. Buddies: []BuddyInfo{
  233. offlineWebAPIBuddy("secondinslice", "secondInSlice"),
  234. offlineWebAPIBuddy("firstinslice", "firstInSlice"),
  235. },
  236. },
  237. },
  238. },
  239. {
  240. name: "group order follows root order TLV not feedbag slice order",
  241. fb: []wire.FeedbagItem{
  242. {
  243. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  244. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{200, 100})}},
  245. },
  246. {Name: "Family", GroupID: 200, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  247. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{2})}}},
  248. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  249. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  250. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "inBuddies", TLVLBlock: wire.TLVLBlock{}},
  251. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 200, Name: "inFamily", TLVLBlock: wire.TLVLBlock{}},
  252. },
  253. want: []BuddyGroup{
  254. {
  255. Name: "Family",
  256. ID: 200,
  257. Buddies: []BuddyInfo{offlineWebAPIBuddy("infamily", "inFamily")},
  258. },
  259. {
  260. Name: "Buddies",
  261. ID: 100,
  262. Buddies: []BuddyInfo{offlineWebAPIBuddy("inbuddies", "inBuddies")},
  263. },
  264. },
  265. },
  266. {
  267. name: "unnamed group becomes Buddies",
  268. fb: []wire.FeedbagItem{
  269. {
  270. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  271. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  272. },
  273. {Name: "", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  274. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  275. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "solo", TLVLBlock: wire.TLVLBlock{}},
  276. },
  277. want: []BuddyGroup{
  278. {
  279. Name: "Buddies",
  280. ID: 100,
  281. Buddies: []BuddyInfo{offlineWebAPIBuddy("solo", "solo")},
  282. },
  283. },
  284. },
  285. }
  286. for _, tt := range tests {
  287. t.Run(tt.name, func(t *testing.T) {
  288. fs := newMockFeedbagService(t)
  289. // No buddy arrives, so every buddy resolves to offline, keeping the
  290. // focus on feedbag -> group conversion.
  291. if tt.fbErr != nil {
  292. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(wire.SNACMessage{}, tt.fbErr).Once()
  293. } else {
  294. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  295. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: tt.fb}}, nil,
  296. ).Once()
  297. }
  298. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  299. sess := &Session{
  300. ScreenName: state.DisplayScreenName(owner.String()),
  301. OSCARSession: state.NewSession().AddInstance(),
  302. logger: slog.Default(),
  303. }
  304. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  305. got, err := m.GetBuddyListForUser(ctx, sess)
  306. if tt.wantErr != "" {
  307. assert.ErrorContains(t, err, tt.wantErr)
  308. assert.Nil(t, got)
  309. return
  310. }
  311. assert.NoError(t, err)
  312. assert.Equal(t, tt.want, got)
  313. })
  314. }
  315. }
  316. func TestBuddyListManager_GetBuddyListForUser_DisplayIDFromPresenceView(t *testing.T) {
  317. // Feedbag buddy names are stored normalized, so an online buddy's display
  318. // name can only come from the user info their arrival carried.
  319. ctx := context.Background()
  320. fb := []wire.FeedbagItem{
  321. {
  322. Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  323. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
  324. },
  325. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  326. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  327. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
  328. }
  329. fs := newMockFeedbagService(t)
  330. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  331. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  332. ).Once()
  333. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  334. sess := &Session{
  335. ScreenName: state.DisplayScreenName("listowner"),
  336. OSCARSession: state.NewSession().AddInstance(),
  337. logger: slog.Default(),
  338. }
  339. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  340. buddyArrives(sess, onlineBuddy("Mike Kelly"))
  341. got, err := m.GetBuddyListForUser(ctx, sess)
  342. require.NoError(t, err)
  343. require.Len(t, got, 1)
  344. require.Len(t, got[0].Buddies, 1)
  345. assert.Equal(t, "mikekelly", got[0].Buddies[0].AimID)
  346. assert.Equal(t, "Mike Kelly", got[0].Buddies[0].DisplayID)
  347. assert.Equal(t, "online", got[0].Buddies[0].State)
  348. }
  349. // A departure keeps the display name the buddy last arrived with, rather than
  350. // falling back to the normalized feedbag name.
  351. func TestBuddyListManager_GetBuddyListForUser_DepartureKeepsDisplayID(t *testing.T) {
  352. ctx := context.Background()
  353. fb := []wire.FeedbagItem{
  354. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  355. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  356. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  357. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  358. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
  359. }
  360. fs := newMockFeedbagService(t)
  361. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  362. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  363. ).Once()
  364. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  365. sess := &Session{
  366. ScreenName: state.DisplayScreenName("listowner"),
  367. OSCARSession: state.NewSession().AddInstance(),
  368. BaseURL: "http://api.example.com",
  369. logger: slog.Default(),
  370. }
  371. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  372. buddyArrives(sess, bartBuddy("Mike Kelly", testIconBART, testStatusBART))
  373. buddyDeparts(sess, "Mike Kelly")
  374. got, err := m.GetBuddyListForUser(ctx, sess)
  375. require.NoError(t, err)
  376. require.Len(t, got[0].Buddies, 1)
  377. buddy := got[0].Buddies[0]
  378. assert.Equal(t, "offline", buddy.State)
  379. assert.Equal(t, "Mike Kelly", buddy.DisplayID)
  380. // An offline buddy publishes neither their icon nor their status message.
  381. assert.Empty(t, buddy.BuddyIcon)
  382. assert.Empty(t, buddy.StatusMsg)
  383. }
  384. // Icons are published only for online buddies: one with an icon gets a
  385. // content-addressed URL, one without gets the placeholder URL, and an offline
  386. // buddy gets none, so neither their icon nor its hash leaks. The hash rides in on
  387. // the arrival, so no metadata lookup happens.
  388. func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T) {
  389. ctx := context.Background()
  390. fb := []wire.FeedbagItem{
  391. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  392. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  393. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  394. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2, 3})}}},
  395. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "onlineicon"},
  396. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "offlinebud"},
  397. {ItemID: 3, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "onlinenoicon"},
  398. }
  399. fs := newMockFeedbagService(t)
  400. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  401. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  402. ).Once()
  403. iconRetriever := newMockBuddyIconRetriever(t)
  404. m := NewBuddyListManager(fs, newMockLocateService(t), BuddyIconSource{
  405. IconRetriever: iconRetriever,
  406. Logger: slog.Default(),
  407. }, slog.Default())
  408. sess := &Session{
  409. ScreenName: state.DisplayScreenName("listowner"),
  410. OSCARSession: state.NewSession().AddInstance(),
  411. BaseURL: "http://api.example.com",
  412. logger: slog.Default(),
  413. }
  414. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  415. buddyArrives(sess, bartBuddy("onlineicon", wire.BARTID{
  416. Type: wire.BARTTypesBuddyIcon,
  417. BARTInfo: wire.BARTInfo{Hash: []byte{0xab, 0xcd}},
  418. }))
  419. buddyArrives(sess, onlineBuddy("onlinenoicon"))
  420. got, err := m.GetBuddyListForUser(ctx, sess)
  421. require.NoError(t, err)
  422. require.Len(t, got, 1)
  423. require.Len(t, got[0].Buddies, 3)
  424. // onlineicon: content-addressed URL carrying the icon hash.
  425. assert.Equal(t, "online", got[0].Buddies[0].State)
  426. assert.Equal(t,
  427. "http://api.example.com/expressions/get?t=onlineicon&type=buddyIcon&bartId=abcd",
  428. got[0].Buddies[0].BuddyIcon)
  429. // offlinebud: never arrived, so no icon.
  430. assert.Equal(t, "offline", got[0].Buddies[1].State)
  431. assert.Empty(t, got[0].Buddies[1].BuddyIcon)
  432. // onlinenoicon: hash-less placeholder URL so a cleared icon still propagates.
  433. assert.Equal(t, "online", got[0].Buddies[2].State)
  434. assert.Equal(t,
  435. "http://api.example.com/expressions/get?t=onlinenoicon&type=buddyIcon",
  436. got[0].Buddies[2].BuddyIcon)
  437. // The arrivals carried every hash, so the roster costs no metadata lookups.
  438. iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
  439. }
  440. // The roster is where a client first reads a buddy's status message.
  441. func TestBuddyListManager_GetBuddyListForUser_PublishesStatusMessages(t *testing.T) {
  442. ctx := context.Background()
  443. fb := []wire.FeedbagItem{
  444. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  445. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  446. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  447. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2})}}},
  448. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "hasstatus"},
  449. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "nostatus"},
  450. }
  451. fs := newMockFeedbagService(t)
  452. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  453. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  454. ).Once()
  455. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  456. sess := &Session{
  457. ScreenName: state.DisplayScreenName("listowner"),
  458. OSCARSession: state.NewSession().AddInstance(),
  459. BaseURL: "http://api.example.com",
  460. logger: slog.Default(),
  461. }
  462. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  463. buddyArrives(sess, bartBuddy("hasstatus", testStatusBART))
  464. buddyArrives(sess, onlineBuddy("nostatus"))
  465. got, err := m.GetBuddyListForUser(ctx, sess)
  466. require.NoError(t, err)
  467. require.Len(t, got, 1)
  468. require.Len(t, got[0].Buddies, 2)
  469. assert.Equal(t, "brb", got[0].Buddies[0].StatusMsg)
  470. assert.Empty(t, got[0].Buddies[1].StatusMsg)
  471. }
  472. // The roster is rebuilt on every feedbag change, so only an unavailable buddy may
  473. // cost a locate query. Mockery fails the test on any other call.
  474. func TestBuddyListManager_GetBuddyListForUser_QueriesOnlyAwayBuddies(t *testing.T) {
  475. ctx := context.Background()
  476. fb := []wire.FeedbagItem{
  477. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  478. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  479. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  480. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2, 3})}}},
  481. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "onlinebud"},
  482. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "awaybud"},
  483. {ItemID: 3, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "neverseen"},
  484. }
  485. fs := newMockFeedbagService(t)
  486. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  487. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  488. ).Once()
  489. ls := newMockLocateService(t)
  490. // Only the away buddy earns a query.
  491. ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything,
  492. mock.MatchedBy(func(q wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool {
  493. return q.ScreenName == "awaybud"
  494. })).
  495. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  496. TLVUserInfo: wire.TLVUserInfo{ScreenName: "awaybud"},
  497. LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
  498. wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "out to lunch"),
  499. }},
  500. }}, nil).Once()
  501. m := NewBuddyListManager(fs, ls, newTestIconSource(t), slog.Default())
  502. sess := &Session{
  503. ScreenName: state.DisplayScreenName("listowner"),
  504. OSCARSession: state.NewSession().AddInstance(),
  505. logger: slog.Default(),
  506. }
  507. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  508. away := wire.TLVUserInfo{ScreenName: "awaybud"}
  509. away.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
  510. buddyArrives(sess, onlineBuddy("onlinebud"))
  511. buddyArrives(sess, away)
  512. got, err := m.GetBuddyListForUser(ctx, sess)
  513. require.NoError(t, err)
  514. require.Len(t, got[0].Buddies, 3)
  515. assert.Equal(t, "online", got[0].Buddies[0].State)
  516. assert.Equal(t, "away", got[0].Buddies[1].State)
  517. // The away message is the one field a presence broadcast cannot carry.
  518. assert.Equal(t, "out to lunch", got[0].Buddies[1].AwayMsg)
  519. // A buddy no arrival has been relayed for renders offline rather than
  520. // triggering a lookup.
  521. assert.Equal(t, "offline", got[0].Buddies[2].State)
  522. }
  523. // The feedbag service relays a session's own writes only to the owner's other
  524. // instances, so renaming a buddy from the web client produces no SNAC for that
  525. // session. Without an explicit invalidation, its cached aliases would keep serving
  526. // the old name and the next presence or IM event would rename the buddy back.
  527. func TestBuddyListManager_SetBuddyAttributeInFeedbag_InvalidatesAliasCache(t *testing.T) {
  528. ctx := context.Background()
  529. feedbag := func(alias string) []wire.FeedbagItem {
  530. buddy := wire.FeedbagItem{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"}
  531. buddy.TLVLBlock = wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, alias)}}
  532. return []wire.FeedbagItem{
  533. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  534. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  535. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  536. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  537. buddy,
  538. }
  539. }
  540. fs := newMockFeedbagService(t)
  541. // Query 1: the alias cache loads. Query 2: SetBuddyAttributeInFeedbag reads the
  542. // feedbag it is about to rewrite. Query 3: the cache reloads post-invalidation,
  543. // now seeing the stored rename.
  544. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  545. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: feedbag("MICHAELKELLY")}}, nil).Twice()
  546. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  547. Return(&wire.SNACMessage{}, nil).Once()
  548. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  549. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: feedbag("MIKE")}}, nil).Once()
  550. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  551. sess := &Session{
  552. ScreenName: state.DisplayScreenName("listowner"),
  553. OSCARSession: state.NewSession().AddInstance(),
  554. }
  555. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  556. require.Equal(t, "MICHAELKELLY", sess.Aliases(ctx)["mikekelly"])
  557. resultCode, err := m.SetBuddyAttributeInFeedbag(ctx, sess, "mikekelly", "MIKE")
  558. require.NoError(t, err)
  559. require.Equal(t, "success", resultCode)
  560. assert.Equal(t, "MIKE", sess.Aliases(ctx)["mikekelly"])
  561. }
  562. func TestFeedbagGroupMatchesRequested(t *testing.T) {
  563. assert.True(t, feedbagGroupMatchesRequested("Buddies", "Buddies"))
  564. assert.True(t, feedbagGroupMatchesRequested("", "Buddies"))
  565. assert.True(t, feedbagGroupMatchesRequested(" ", "Buddies"))
  566. assert.True(t, feedbagGroupMatchesRequested("Friends", "friends"))
  567. assert.False(t, feedbagGroupMatchesRequested("", "Friends"))
  568. }
  569. func TestStoredGroupNameForRequest(t *testing.T) {
  570. items := []wire.FeedbagItem{
  571. {ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "", GroupID: 1},
  572. {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "jon", GroupID: 1},
  573. }
  574. st, ok := storedGroupNameForRequest(items, "Buddies")
  575. assert.True(t, ok)
  576. assert.Equal(t, "", st)
  577. items2 := []wire.FeedbagItem{
  578. {ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 2},
  579. }
  580. st2, ok2 := storedGroupNameForRequest(items2, "Friends")
  581. assert.True(t, ok2)
  582. assert.Equal(t, "Friends", st2)
  583. }
  584. // Removing a buddy stops any further presence SNAC for them, so their cached
  585. // presence is dropped. A buddy listed in more than one group is still watched
  586. // after leaving one of them, and keeps their entry.
  587. func TestBuddyListManager_RemoveBuddyFromFeedbag_ForgetsPresence(t *testing.T) {
  588. ctx := context.Background()
  589. // feedbag places mikekelly in every group named.
  590. feedbag := func(groups ...string) []wire.FeedbagItem {
  591. items := []wire.FeedbagItem{
  592. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  593. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100, 200})}}},
  594. }
  595. for i, group := range groups {
  596. gid := uint16(100 * (i + 1))
  597. itemID := uint16(i + 1)
  598. items = append(items,
  599. wire.FeedbagItem{Name: group, GroupID: gid, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  600. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{itemID})}}},
  601. wire.FeedbagItem{ItemID: itemID, ClassID: wire.FeedbagClassIdBuddy, GroupID: gid, Name: "mikekelly"},
  602. )
  603. }
  604. return items
  605. }
  606. tests := []struct {
  607. name string
  608. fb []wire.FeedbagItem
  609. group string
  610. allGroups bool
  611. wantForgot bool
  612. description string
  613. }{
  614. {
  615. name: "sole listing forgets the buddy",
  616. fb: feedbag("Buddies"),
  617. group: "Buddies",
  618. wantForgot: true,
  619. },
  620. {
  621. name: "removing from all groups forgets the buddy",
  622. fb: feedbag("Buddies", "Work"),
  623. allGroups: true,
  624. wantForgot: true,
  625. },
  626. {
  627. // Still on the list via the other group, so still watched.
  628. name: "removal from one of two groups keeps the buddy",
  629. fb: feedbag("Buddies", "Work"),
  630. group: "Buddies",
  631. wantForgot: false,
  632. },
  633. }
  634. for _, tt := range tests {
  635. t.Run(tt.name, func(t *testing.T) {
  636. fs := newMockFeedbagService(t)
  637. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  638. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: tt.fb}}, nil,
  639. ).Maybe()
  640. fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  641. Return(&wire.SNACMessage{}, nil).Once()
  642. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  643. Return(&wire.SNACMessage{}, nil).Maybe()
  644. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  645. sess := &Session{
  646. ScreenName: state.DisplayScreenName("listowner"),
  647. OSCARSession: state.NewSession().AddInstance(),
  648. logger: slog.Default(),
  649. }
  650. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  651. buddyArrives(sess, onlineBuddy("Mike Kelly"))
  652. resultCode, err := m.RemoveBuddyFromFeedbag(ctx, sess, "mikekelly", tt.group, tt.allGroups)
  653. require.NoError(t, err)
  654. require.Equal(t, "success", resultCode)
  655. _, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
  656. assert.Equal(t, !tt.wantForgot, ok)
  657. })
  658. }
  659. }
  660. // Deleting a group deletes the buddies in it.
  661. func TestBuddyListManager_RemoveGroupFromFeedbag_ForgetsPresence(t *testing.T) {
  662. ctx := context.Background()
  663. fb := []wire.FeedbagItem{
  664. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  665. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  666. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  667. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  668. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
  669. }
  670. fs := newMockFeedbagService(t)
  671. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  672. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil,
  673. ).Maybe()
  674. fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  675. Return(&wire.SNACMessage{}, nil).Once()
  676. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  677. Return(&wire.SNACMessage{}, nil).Maybe()
  678. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  679. sess := &Session{
  680. ScreenName: state.DisplayScreenName("listowner"),
  681. OSCARSession: state.NewSession().AddInstance(),
  682. logger: slog.Default(),
  683. }
  684. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  685. buddyArrives(sess, onlineBuddy("Mike Kelly"))
  686. resultCode, err := m.RemoveGroupFromFeedbag(ctx, sess, "Buddies")
  687. require.NoError(t, err)
  688. require.Equal(t, "success", resultCode)
  689. _, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
  690. assert.False(t, ok)
  691. }
  692. // A method that rewrites the feedbag must re-read it even when the cache is warm:
  693. // it computes a pending diff from what it reads.
  694. func TestBuddyListManager_MutationsReReadTheFeedbag(t *testing.T) {
  695. ctx := context.Background()
  696. fb := []wire.FeedbagItem{
  697. {Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  698. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}}},
  699. {Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
  700. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}}},
  701. {ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
  702. }
  703. var queries int
  704. fs := newMockFeedbagService(t)
  705. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  706. RunAndReturn(func(context.Context, *state.SessionInstance, wire.SNACFrame) (wire.SNACMessage, error) {
  707. queries++
  708. return wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil
  709. })
  710. fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  711. Return(&wire.SNACMessage{}, nil).Once()
  712. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  713. Return(&wire.SNACMessage{}, nil).Maybe()
  714. m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  715. sess := &Session{
  716. ScreenName: state.DisplayScreenName("listowner"),
  717. OSCARSession: state.NewSession().AddInstance(),
  718. logger: slog.Default(),
  719. }
  720. sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
  721. _, err := m.GetBuddyListForUser(ctx, sess)
  722. require.NoError(t, err)
  723. require.Equal(t, 1, queries)
  724. _, err = m.GetBuddyListForUser(ctx, sess)
  725. require.NoError(t, err)
  726. require.Equal(t, 1, queries, "a warm cache serves the roster without a read")
  727. _, err = m.RemoveBuddyFromFeedbag(ctx, sess, "mikekelly", "Buddies", false)
  728. require.NoError(t, err)
  729. assert.Equal(t, 2, queries, "the mutation re-reads rather than trusting the cache")
  730. _, err = m.GetBuddyListForUser(ctx, sess)
  731. require.NoError(t, err)
  732. assert.Equal(t, 3, queries, "the write invalidated the cache")
  733. }