buddylist_handler_test.go 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173
  1. package webapi
  2. import (
  3. "context"
  4. "errors"
  5. "log/slog"
  6. "net/http"
  7. "net/http/httptest"
  8. "net/url"
  9. "testing"
  10. "time"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/mock"
  13. "github.com/mk6i/open-oscar-server/state"
  14. "github.com/mk6i/open-oscar-server/wire"
  15. )
  16. func TestBuddyListHandler_AddBuddy(t *testing.T) {
  17. tests := []struct {
  18. name string
  19. queryParams map[string][]string
  20. setupMocks func(*mockSessionResolver, *mockFeedbagService, *mockFeedbagService, string) *Session
  21. expectedStatusCode int
  22. expectedResponse string
  23. }{
  24. {
  25. name: "Success_AddBuddyToExistingGroup",
  26. queryParams: map[string][]string{
  27. "aimsid": {"test-session"},
  28. "buddy": {"newbuddy"},
  29. "group": {"Friends"},
  30. },
  31. setupMocks: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
  32. session := &Session{
  33. AimSID: aimsid,
  34. ScreenName: state.DisplayScreenName("testuser"),
  35. OSCARSession: state.NewSession().AddInstance(),
  36. EventQueue: NewEventQueue(100),
  37. LastAccessed: time.Now(),
  38. }
  39. items := []wire.FeedbagItem{
  40. {GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  41. }
  42. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  43. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  44. // addBuddyToFeedbag calls UpsertItem twice: once for group order update, once for buddy insert
  45. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  46. Return((*wire.SNACMessage)(nil), nil)
  47. return session
  48. },
  49. expectedStatusCode: http.StatusOK,
  50. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  51. },
  52. {
  53. name: "Error_BuddyAlreadyExists",
  54. queryParams: map[string][]string{
  55. "aimsid": {"test-session"},
  56. "buddy": {"existingbuddy"},
  57. "group": {"Friends"},
  58. },
  59. setupMocks: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
  60. session := &Session{
  61. AimSID: aimsid,
  62. ScreenName: state.DisplayScreenName("testuser"),
  63. OSCARSession: state.NewSession().AddInstance(),
  64. EventQueue: NewEventQueue(100),
  65. LastAccessed: time.Now(),
  66. }
  67. // Friends group with existingbuddy already present
  68. items := []wire.FeedbagItem{
  69. {GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  70. {GroupID: 1, ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "existingbuddy"},
  71. }
  72. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  73. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
  74. return session
  75. },
  76. expectedStatusCode: http.StatusOK,
  77. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"alreadyExists"}}}`,
  78. },
  79. {
  80. name: "Error_MissingBuddyParameter",
  81. queryParams: map[string][]string{
  82. "aimsid": {"test-session"},
  83. "group": {"Friends"},
  84. },
  85. setupMocks: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
  86. return &Session{
  87. AimSID: aimsid,
  88. ScreenName: state.DisplayScreenName("testuser"),
  89. EventQueue: NewEventQueue(100),
  90. LastAccessed: time.Now(),
  91. }
  92. },
  93. expectedStatusCode: http.StatusBadRequest,
  94. expectedResponse: `{"response":{"statusCode":400,"statusText":"missing buddy parameter","data":{}}}`,
  95. },
  96. }
  97. for _, tt := range tests {
  98. t.Run(tt.name, func(t *testing.T) {
  99. sessionManager := newMockSessionResolver(t)
  100. feedbagService := newMockFeedbagService(t)
  101. blmFeedbagService := newMockFeedbagService(t)
  102. blm := NewBuddyListManager(blmFeedbagService, newMockLocateService(t), newTestIconSource(t), slog.Default())
  103. logger := slog.Default()
  104. handler := &BuddyListHandler{
  105. FeedbagService: feedbagService,
  106. BuddyListManager: blm,
  107. Logger: logger,
  108. }
  109. aimsid := ""
  110. if aimsids, ok := tt.queryParams["aimsid"]; ok && len(aimsids) > 0 {
  111. aimsid = aimsids[0]
  112. }
  113. session := tt.setupMocks(sessionManager, feedbagService, blmFeedbagService, aimsid)
  114. reqURL := "/buddylist/addBuddy"
  115. if len(tt.queryParams) > 0 {
  116. values := url.Values{}
  117. for key, vals := range tt.queryParams {
  118. for _, val := range vals {
  119. values.Add(key, val)
  120. }
  121. }
  122. reqURL += "?" + values.Encode()
  123. }
  124. req, err := http.NewRequest("GET", reqURL, nil)
  125. assert.NoError(t, err)
  126. rr := httptest.NewRecorder()
  127. handler.AddBuddy(rr, req, session)
  128. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  129. assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
  130. })
  131. }
  132. }
  133. func TestBuddyListHandler_AddGroup(t *testing.T) {
  134. type setupFunc func(*mockSessionResolver, *mockFeedbagService, *mockFeedbagService, string) *Session
  135. newSession := func(aimsid string) *Session {
  136. return &Session{
  137. AimSID: aimsid,
  138. ScreenName: state.DisplayScreenName("testuser"),
  139. OSCARSession: state.NewSession().AddInstance(),
  140. EventQueue: NewEventQueue(100),
  141. LastAccessed: time.Now(),
  142. }
  143. }
  144. tests := []struct {
  145. name string
  146. queryParams map[string][]string
  147. setup setupFunc
  148. expectedStatusCode int
  149. expectedResponse string
  150. }{
  151. {
  152. name: "Error_MissingGroupParam",
  153. queryParams: map[string][]string{"aimsid": {"sess"}},
  154. setup: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
  155. return newSession(aimsid)
  156. },
  157. expectedStatusCode: http.StatusBadRequest,
  158. expectedResponse: `{"response":{"statusCode":400,"statusText":"missing group parameter","data":{}}}`,
  159. },
  160. {
  161. name: "Success_GroupAdded",
  162. queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NewGroup"}},
  163. setup: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
  164. sess := newSession(aimsid)
  165. // Empty feedbag — AddGroup will create root + NewGroup in pending
  166. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  167. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
  168. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  169. Return((*wire.SNACMessage)(nil), nil).Once()
  170. return sess
  171. },
  172. expectedStatusCode: http.StatusOK,
  173. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  174. },
  175. {
  176. name: "Success_GroupAlreadyExists",
  177. queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
  178. setup: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
  179. sess := newSession(aimsid)
  180. items := []wire.FeedbagItem{
  181. {GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  182. }
  183. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  184. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
  185. return sess
  186. },
  187. expectedStatusCode: http.StatusOK,
  188. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"alreadyExists"}}}`,
  189. },
  190. {
  191. name: "Error_FeedbagQueryFails",
  192. queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NewGroup"}},
  193. setup: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
  194. sess := newSession(aimsid)
  195. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  196. Return(wire.SNACMessage{}, errors.New("feedbag error"))
  197. return sess
  198. },
  199. expectedStatusCode: http.StatusOK,
  200. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"error"}}}`,
  201. },
  202. }
  203. for _, tt := range tests {
  204. t.Run(tt.name, func(t *testing.T) {
  205. sm := newMockSessionResolver(t)
  206. fs := newMockFeedbagService(t)
  207. blmFs := newMockFeedbagService(t)
  208. blm := NewBuddyListManager(blmFs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  209. aimsid := ""
  210. if v := tt.queryParams["aimsid"]; len(v) > 0 {
  211. aimsid = v[0]
  212. }
  213. session := tt.setup(sm, fs, blmFs, aimsid)
  214. handler := &BuddyListHandler{
  215. FeedbagService: fs,
  216. BuddyListManager: blm,
  217. Logger: slog.Default(),
  218. }
  219. values := url.Values{}
  220. for k, vs := range tt.queryParams {
  221. for _, v := range vs {
  222. values.Add(k, v)
  223. }
  224. }
  225. req, _ := http.NewRequest("GET", "/buddylist/addGroup?"+values.Encode(), nil)
  226. rr := httptest.NewRecorder()
  227. handler.AddGroup(rr, req, session)
  228. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  229. assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
  230. })
  231. }
  232. }
  233. func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
  234. type setupFunc func(*mockSessionResolver, *BuddyListManager, *mockFeedbagService, string) *Session
  235. newBuddyListManager := func(t *testing.T, fs *mockFeedbagService) *BuddyListManager {
  236. return NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  237. }
  238. tests := []struct {
  239. name string
  240. queryParams map[string][]string
  241. setup setupFunc
  242. expectedStatusCode int
  243. expectedResponse string
  244. }{
  245. {
  246. name: "Error_MissingBuddyParam",
  247. queryParams: map[string][]string{"aimsid": {"sess"}},
  248. setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
  249. return &Session{AimSID: aimsid, ScreenName: state.DisplayScreenName("testuser"), LastAccessed: time.Now()}
  250. },
  251. expectedStatusCode: http.StatusBadRequest,
  252. expectedResponse: `{"response":{"statusCode":400,"statusText":"missing buddy parameter","data":{}}}`,
  253. },
  254. {
  255. name: "Success_BuddyRemoved",
  256. queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"someBuddy"}, "group": {"Friends"}},
  257. setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
  258. sess := &Session{
  259. AimSID: aimsid,
  260. ScreenName: state.DisplayScreenName("testuser"),
  261. OSCARSession: state.NewSession().AddInstance(),
  262. EventQueue: NewEventQueue(100),
  263. LastAccessed: time.Now(),
  264. }
  265. items := []wire.FeedbagItem{
  266. {GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  267. {GroupID: 1, ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "someBuddy"},
  268. }
  269. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  270. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  271. fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  272. Return((*wire.SNACMessage)(nil), nil).Once()
  273. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  274. Return((*wire.SNACMessage)(nil), nil).Once()
  275. return sess
  276. },
  277. expectedStatusCode: http.StatusOK,
  278. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  279. },
  280. {
  281. name: "Success_BuddyNotFound",
  282. queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"ghost"}, "group": {"Friends"}},
  283. setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
  284. sess := &Session{
  285. AimSID: aimsid,
  286. ScreenName: state.DisplayScreenName("testuser"),
  287. OSCARSession: state.NewSession().AddInstance(),
  288. LastAccessed: time.Now(),
  289. }
  290. // Group exists but "ghost" is not in it
  291. items := []wire.FeedbagItem{
  292. {GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  293. }
  294. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  295. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
  296. return sess
  297. },
  298. expectedStatusCode: http.StatusOK,
  299. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
  300. },
  301. {
  302. name: "Success_GroupNotFound",
  303. queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"someBuddy"}, "group": {"NoSuchGroup"}},
  304. setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
  305. sess := &Session{
  306. AimSID: aimsid,
  307. ScreenName: state.DisplayScreenName("testuser"),
  308. OSCARSession: state.NewSession().AddInstance(),
  309. LastAccessed: time.Now(),
  310. }
  311. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  312. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil)
  313. return sess
  314. },
  315. expectedStatusCode: http.StatusOK,
  316. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
  317. },
  318. }
  319. for _, tt := range tests {
  320. t.Run(tt.name, func(t *testing.T) {
  321. sm := newMockSessionResolver(t)
  322. fs := newMockFeedbagService(t)
  323. blm := newBuddyListManager(t, fs)
  324. aimsid := ""
  325. if v := tt.queryParams["aimsid"]; len(v) > 0 {
  326. aimsid = v[0]
  327. }
  328. session := tt.setup(sm, blm, fs, aimsid)
  329. handler := &BuddyListHandler{
  330. BuddyListManager: blm,
  331. Logger: slog.Default(),
  332. }
  333. values := url.Values{}
  334. for k, vs := range tt.queryParams {
  335. for _, v := range vs {
  336. values.Add(k, v)
  337. }
  338. }
  339. req, _ := http.NewRequest("GET", "/buddylist/removeBuddy?"+values.Encode(), nil)
  340. rr := httptest.NewRecorder()
  341. handler.RemoveBuddy(rr, req, session)
  342. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  343. assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
  344. })
  345. }
  346. }
  347. func TestBuddyListHandler_RemoveGroup(t *testing.T) {
  348. type setupFunc func(*mockSessionResolver, *BuddyListManager, *mockFeedbagService, string) *Session
  349. newBuddyListManager := func(t *testing.T, fs *mockFeedbagService) *BuddyListManager {
  350. return NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  351. }
  352. tests := []struct {
  353. name string
  354. queryParams map[string][]string
  355. setup setupFunc
  356. expectedStatusCode int
  357. expectedResponse string
  358. }{
  359. {
  360. name: "Error_MissingGroupParam",
  361. queryParams: map[string][]string{"aimsid": {"sess"}},
  362. setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
  363. return &Session{AimSID: aimsid, ScreenName: state.DisplayScreenName("testuser"), LastAccessed: time.Now()}
  364. },
  365. expectedStatusCode: http.StatusBadRequest,
  366. expectedResponse: `{"response":{"statusCode":400,"statusText":"missing group parameter","data":{}}}`,
  367. },
  368. {
  369. name: "Success_GroupRemoved",
  370. queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
  371. setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
  372. sess := &Session{
  373. AimSID: aimsid,
  374. ScreenName: state.DisplayScreenName("testuser"),
  375. OSCARSession: state.NewSession().AddInstance(),
  376. EventQueue: NewEventQueue(100),
  377. LastAccessed: time.Now(),
  378. }
  379. // Root order record + Friends group; DeleteGroup will delete Friends and update root.
  380. items := []wire.FeedbagItem{
  381. {
  382. GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "",
  383. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
  384. wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1}),
  385. }},
  386. },
  387. {GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  388. }
  389. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  390. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  391. fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  392. Return((*wire.SNACMessage)(nil), nil).Once()
  393. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  394. Return((*wire.SNACMessage)(nil), nil).Once()
  395. return sess
  396. },
  397. expectedStatusCode: http.StatusOK,
  398. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  399. },
  400. {
  401. name: "Success_GroupNotFound",
  402. queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NoSuchGroup"}},
  403. setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
  404. sess := &Session{
  405. AimSID: aimsid,
  406. ScreenName: state.DisplayScreenName("testuser"),
  407. OSCARSession: state.NewSession().AddInstance(),
  408. LastAccessed: time.Now(),
  409. }
  410. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  411. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil)
  412. return sess
  413. },
  414. expectedStatusCode: http.StatusOK,
  415. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
  416. },
  417. }
  418. for _, tt := range tests {
  419. t.Run(tt.name, func(t *testing.T) {
  420. sm := newMockSessionResolver(t)
  421. fs := newMockFeedbagService(t)
  422. blm := newBuddyListManager(t, fs)
  423. aimsid := ""
  424. if v := tt.queryParams["aimsid"]; len(v) > 0 {
  425. aimsid = v[0]
  426. }
  427. session := tt.setup(sm, blm, fs, aimsid)
  428. handler := &BuddyListHandler{
  429. BuddyListManager: blm,
  430. Logger: slog.Default(),
  431. }
  432. values := url.Values{}
  433. for k, vs := range tt.queryParams {
  434. for _, v := range vs {
  435. values.Add(k, v)
  436. }
  437. }
  438. req, _ := http.NewRequest("GET", "/buddylist/removeGroup?"+values.Encode(), nil)
  439. rr := httptest.NewRecorder()
  440. handler.RemoveGroup(rr, req, session)
  441. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  442. assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
  443. })
  444. }
  445. }
  446. func TestRequireSession(t *testing.T) {
  447. tests := []struct {
  448. name string
  449. aimsid string
  450. setupMocks func(*mockSessionResolver, string)
  451. expectedStatusCode int
  452. expectedResponse string
  453. expectNextCalled bool
  454. }{
  455. {
  456. name: "Error_MissingAimsid",
  457. aimsid: "",
  458. setupMocks: func(sm *mockSessionResolver, aimsid string) {},
  459. expectedStatusCode: http.StatusBadRequest,
  460. expectedResponse: `{"response":{"statusCode":400,"statusText":"missing aimsid parameter","data":{}}}`,
  461. expectNextCalled: false,
  462. },
  463. {
  464. name: "Error_SessionNotFound",
  465. aimsid: "unknown-session",
  466. setupMocks: func(sm *mockSessionResolver, aimsid string) {
  467. sm.EXPECT().GetSession(mock.Anything, aimsid).Return(nil, ErrNoWebAPISession)
  468. },
  469. expectedStatusCode: http.StatusUnauthorized,
  470. expectedResponse: `{"response":{"statusCode":401,"statusText":"invalid or expired session","data":{}}}`,
  471. expectNextCalled: false,
  472. },
  473. {
  474. name: "Error_SessionExpired",
  475. aimsid: "expired-session",
  476. setupMocks: func(sm *mockSessionResolver, aimsid string) {
  477. sm.EXPECT().GetSession(mock.Anything, aimsid).Return(nil, ErrWebAPISessionExpired)
  478. },
  479. expectedStatusCode: http.StatusUnauthorized,
  480. expectedResponse: `{"response":{"statusCode":401,"statusText":"invalid or expired session","data":{}}}`,
  481. expectNextCalled: false,
  482. },
  483. {
  484. name: "Error_InternalSessionError",
  485. aimsid: "some-session",
  486. setupMocks: func(sm *mockSessionResolver, aimsid string) {
  487. sm.EXPECT().GetSession(mock.Anything, aimsid).Return(nil, errors.New("db error"))
  488. },
  489. expectedStatusCode: http.StatusUnauthorized,
  490. expectedResponse: `{"response":{"statusCode":401,"statusText":"invalid or expired session","data":{}}}`,
  491. expectNextCalled: false,
  492. },
  493. {
  494. name: "Success_PassesSessionToNext",
  495. aimsid: "valid-session",
  496. setupMocks: func(sm *mockSessionResolver, aimsid string) {
  497. sess := &Session{
  498. AimSID: aimsid,
  499. ScreenName: state.DisplayScreenName("testuser"),
  500. OSCARSession: state.NewSession().AddInstance(),
  501. LastAccessed: time.Now(),
  502. }
  503. sm.EXPECT().GetSession(mock.Anything, aimsid).Return(sess, nil)
  504. sm.EXPECT().TouchSession(mock.Anything, aimsid).Return(nil)
  505. },
  506. expectedStatusCode: http.StatusOK,
  507. expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  508. expectNextCalled: true,
  509. },
  510. }
  511. for _, tt := range tests {
  512. t.Run(tt.name, func(t *testing.T) {
  513. sm := newMockSessionResolver(t)
  514. tt.setupMocks(sm, tt.aimsid)
  515. nextCalled := false
  516. next := func(w http.ResponseWriter, r *http.Request, session *Session) {
  517. nextCalled = true
  518. resp := BaseResponse{}
  519. resp.Response.StatusCode = 200
  520. resp.Response.StatusText = "Ok"
  521. SendResponse(w, r, resp, slog.Default())
  522. }
  523. authMiddleware := NewAuthMiddleware(slog.Default())
  524. wrapped := authMiddleware.RequireSession(sm, next)
  525. reqURL := "/buddylist/test"
  526. if tt.aimsid != "" {
  527. reqURL += "?aimsid=" + tt.aimsid
  528. }
  529. req, err := http.NewRequest("GET", reqURL, nil)
  530. assert.NoError(t, err)
  531. rr := httptest.NewRecorder()
  532. wrapped.ServeHTTP(rr, req)
  533. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  534. assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
  535. assert.Equal(t, tt.expectNextCalled, nextCalled)
  536. })
  537. }
  538. }
  539. func TestBuddyListHandler_RenameGroup(t *testing.T) {
  540. newBLM := func(t *testing.T, fs *mockFeedbagService) *BuddyListManager {
  541. return NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  542. }
  543. sessWithOSCAR := func(aimsid string) *Session {
  544. return &Session{
  545. AimSID: aimsid,
  546. ScreenName: state.DisplayScreenName("testuser"),
  547. OSCARSession: state.NewSession().AddInstance(),
  548. EventQueue: NewEventQueue(100),
  549. LastAccessed: time.Now(),
  550. }
  551. }
  552. tests := []struct {
  553. name string
  554. queryParams map[string][]string
  555. setup func(*mockFeedbagService, string) *Session
  556. expectStatusCode int
  557. expectResponse string
  558. }{
  559. {
  560. name: "Error_MissingParam",
  561. queryParams: map[string][]string{"aimsid": {"sess"}, "oldGroup": {"Friends"}},
  562. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  563. return &Session{AimSID: aimsid, LastAccessed: time.Now()}
  564. },
  565. expectStatusCode: http.StatusBadRequest,
  566. expectResponse: `{"response":{"statusCode":400,"statusText":"missing oldGroup or newGroup parameter","data":{}}}`,
  567. },
  568. {
  569. name: "Success",
  570. queryParams: map[string][]string{"aimsid": {"sess"}, "oldGroup": {"Friends"}, "newGroup": {"Pals"}},
  571. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  572. items := []wire.FeedbagItem{
  573. {GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
  574. {GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  575. }
  576. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  577. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  578. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  579. Return((*wire.SNACMessage)(nil), nil).Once()
  580. return sessWithOSCAR(aimsid)
  581. },
  582. expectStatusCode: http.StatusOK,
  583. expectResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  584. },
  585. {
  586. name: "NotFound",
  587. queryParams: map[string][]string{"aimsid": {"sess"}, "oldGroup": {"Ghost"}, "newGroup": {"Pals"}},
  588. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  589. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  590. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
  591. return sessWithOSCAR(aimsid)
  592. },
  593. expectStatusCode: http.StatusOK,
  594. expectResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
  595. },
  596. }
  597. for _, tt := range tests {
  598. t.Run(tt.name, func(t *testing.T) {
  599. fs := newMockFeedbagService(t)
  600. session := tt.setup(fs, "sess")
  601. handler := &BuddyListHandler{BuddyListManager: newBLM(t, fs), Logger: slog.Default()}
  602. values := url.Values{}
  603. for k, vs := range tt.queryParams {
  604. for _, v := range vs {
  605. values.Add(k, v)
  606. }
  607. }
  608. req, _ := http.NewRequest("GET", "/buddylist/renameGroup?"+values.Encode(), nil)
  609. rr := httptest.NewRecorder()
  610. handler.RenameGroup(rr, req, session)
  611. assert.Equal(t, tt.expectStatusCode, rr.Code)
  612. assert.JSONEq(t, tt.expectResponse, rr.Body.String())
  613. })
  614. }
  615. }
  616. func TestBuddyListHandler_MoveBuddy(t *testing.T) {
  617. newBLM := func(t *testing.T, fs *mockFeedbagService) *BuddyListManager {
  618. return NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  619. }
  620. sessWithOSCAR := func(aimsid string) *Session {
  621. return &Session{
  622. AimSID: aimsid,
  623. ScreenName: state.DisplayScreenName("testuser"),
  624. OSCARSession: state.NewSession().AddInstance(),
  625. EventQueue: NewEventQueue(100),
  626. LastAccessed: time.Now(),
  627. }
  628. }
  629. tests := []struct {
  630. name string
  631. queryParams map[string][]string
  632. setup func(*mockFeedbagService, string) *Session
  633. expectStatusCode int
  634. expectResponse string
  635. }{
  636. {
  637. name: "Error_MissingBuddy",
  638. queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
  639. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  640. return &Session{AimSID: aimsid, LastAccessed: time.Now()}
  641. },
  642. expectStatusCode: http.StatusBadRequest,
  643. expectResponse: `{"response":{"statusCode":400,"statusText":"missing buddy parameter","data":{}}}`,
  644. },
  645. {
  646. name: "Success_Reorder",
  647. queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"bob"}, "group": {"Friends"}, "beforeBuddy": {"alice"}},
  648. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  649. items := []wire.FeedbagItem{
  650. {GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends",
  651. TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{
  652. wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10, 20}),
  653. }}},
  654. {GroupID: 1, ItemID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
  655. {GroupID: 1, ItemID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
  656. }
  657. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  658. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  659. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  660. Return((*wire.SNACMessage)(nil), nil).Once()
  661. return sessWithOSCAR(aimsid)
  662. },
  663. expectStatusCode: http.StatusOK,
  664. expectResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  665. },
  666. {
  667. name: "NotFound_Buddy",
  668. queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"ghost"}, "group": {"Friends"}},
  669. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  670. items := []wire.FeedbagItem{
  671. {GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  672. }
  673. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  674. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  675. return sessWithOSCAR(aimsid)
  676. },
  677. expectStatusCode: http.StatusOK,
  678. expectResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
  679. },
  680. }
  681. for _, tt := range tests {
  682. t.Run(tt.name, func(t *testing.T) {
  683. fs := newMockFeedbagService(t)
  684. session := tt.setup(fs, "sess")
  685. handler := &BuddyListHandler{BuddyListManager: newBLM(t, fs), Logger: slog.Default()}
  686. values := url.Values{}
  687. for k, vs := range tt.queryParams {
  688. for _, v := range vs {
  689. values.Add(k, v)
  690. }
  691. }
  692. req, _ := http.NewRequest("GET", "/buddylist/moveBuddy?"+values.Encode(), nil)
  693. rr := httptest.NewRecorder()
  694. handler.MoveBuddy(rr, req, session)
  695. assert.Equal(t, tt.expectStatusCode, rr.Code)
  696. assert.JSONEq(t, tt.expectResponse, rr.Body.String())
  697. })
  698. }
  699. }
  700. func TestBuddyListHandler_SetBuddyAttribute(t *testing.T) {
  701. newBLM := func(t *testing.T, fs *mockFeedbagService) *BuddyListManager {
  702. return NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  703. }
  704. sessWithOSCAR := func(aimsid string) *Session {
  705. return &Session{
  706. AimSID: aimsid,
  707. ScreenName: state.DisplayScreenName("testuser"),
  708. OSCARSession: state.NewSession().AddInstance(),
  709. EventQueue: NewEventQueue(100),
  710. LastAccessed: time.Now(),
  711. }
  712. }
  713. tests := []struct {
  714. name string
  715. queryParams map[string][]string
  716. setup func(*mockFeedbagService, string) *Session
  717. expectStatusCode int
  718. expectResponse string
  719. }{
  720. {
  721. name: "Error_MissingT",
  722. queryParams: map[string][]string{"aimsid": {"sess"}, "friendly": {"Al"}},
  723. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  724. return &Session{AimSID: aimsid, LastAccessed: time.Now()}
  725. },
  726. expectStatusCode: http.StatusBadRequest,
  727. expectResponse: `{"response":{"statusCode":400,"statusText":"missing t parameter","data":{}}}`,
  728. },
  729. {
  730. name: "Success",
  731. queryParams: map[string][]string{"aimsid": {"sess"}, "t": {"alice"}, "friendly": {"Al"}},
  732. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  733. items := []wire.FeedbagItem{
  734. {GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  735. {GroupID: 1, ItemID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
  736. }
  737. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  738. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  739. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  740. Return((*wire.SNACMessage)(nil), nil).Once()
  741. return sessWithOSCAR(aimsid)
  742. },
  743. expectStatusCode: http.StatusOK,
  744. expectResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  745. },
  746. {
  747. name: "NotFound",
  748. queryParams: map[string][]string{"aimsid": {"sess"}, "t": {"ghost"}, "friendly": {"Al"}},
  749. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  750. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  751. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
  752. return sessWithOSCAR(aimsid)
  753. },
  754. expectStatusCode: http.StatusOK,
  755. expectResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
  756. },
  757. }
  758. for _, tt := range tests {
  759. t.Run(tt.name, func(t *testing.T) {
  760. fs := newMockFeedbagService(t)
  761. session := tt.setup(fs, "sess")
  762. handler := &BuddyListHandler{BuddyListManager: newBLM(t, fs), Logger: slog.Default()}
  763. values := url.Values{}
  764. for k, vs := range tt.queryParams {
  765. for _, v := range vs {
  766. values.Add(k, v)
  767. }
  768. }
  769. req, _ := http.NewRequest("GET", "/buddylist/setBuddyAttribute?"+values.Encode(), nil)
  770. rr := httptest.NewRecorder()
  771. handler.SetBuddyAttribute(rr, req, session)
  772. assert.Equal(t, tt.expectStatusCode, rr.Code)
  773. assert.JSONEq(t, tt.expectResponse, rr.Body.String())
  774. })
  775. }
  776. }
  777. func TestBuddyListHandler_SetGroupAttribute(t *testing.T) {
  778. newBLM := func(t *testing.T, fs *mockFeedbagService) *BuddyListManager {
  779. return NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
  780. }
  781. sessWithOSCAR := func(aimsid string) *Session {
  782. return &Session{
  783. AimSID: aimsid,
  784. ScreenName: state.DisplayScreenName("testuser"),
  785. OSCARSession: state.NewSession().AddInstance(),
  786. EventQueue: NewEventQueue(100),
  787. LastAccessed: time.Now(),
  788. }
  789. }
  790. tests := []struct {
  791. name string
  792. queryParams map[string][]string
  793. setup func(*mockFeedbagService, string) *Session
  794. expectStatusCode int
  795. expectResponse string
  796. }{
  797. {
  798. name: "Error_MissingCollapsed",
  799. queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
  800. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  801. return &Session{AimSID: aimsid, LastAccessed: time.Now()}
  802. },
  803. expectStatusCode: http.StatusBadRequest,
  804. expectResponse: `{"response":{"statusCode":400,"statusText":"missing collapsed parameter","data":{}}}`,
  805. },
  806. {
  807. name: "Success",
  808. queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}, "collapsed": {"true"}},
  809. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  810. items := []wire.FeedbagItem{
  811. {GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
  812. {GroupID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  813. }
  814. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  815. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  816. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  817. Return((*wire.SNACMessage)(nil), nil).Once()
  818. return sessWithOSCAR(aimsid)
  819. },
  820. expectStatusCode: http.StatusOK,
  821. expectResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
  822. },
  823. {
  824. name: "NotFound",
  825. queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"NoSuch"}, "collapsed": {"true"}},
  826. setup: func(fs *mockFeedbagService, aimsid string) *Session {
  827. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  828. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: nil}}, nil).Once()
  829. return sessWithOSCAR(aimsid)
  830. },
  831. expectStatusCode: http.StatusOK,
  832. expectResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{"resultCode":"notFound"}}}`,
  833. },
  834. }
  835. for _, tt := range tests {
  836. t.Run(tt.name, func(t *testing.T) {
  837. fs := newMockFeedbagService(t)
  838. session := tt.setup(fs, "sess")
  839. handler := &BuddyListHandler{BuddyListManager: newBLM(t, fs), Logger: slog.Default()}
  840. values := url.Values{}
  841. for k, vs := range tt.queryParams {
  842. for _, v := range vs {
  843. values.Add(k, v)
  844. }
  845. }
  846. req, _ := http.NewRequest("GET", "/buddylist/setGroupAttribute?"+values.Encode(), nil)
  847. rr := httptest.NewRecorder()
  848. handler.SetGroupAttribute(rr, req, session)
  849. assert.Equal(t, tt.expectStatusCode, rr.Code)
  850. assert.JSONEq(t, tt.expectResponse, rr.Body.String())
  851. })
  852. }
  853. }
  854. func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
  855. tests := []struct {
  856. name string
  857. screenName string
  858. buddy string
  859. preAuthorized string
  860. authMsg string
  861. grantErr error
  862. wantGrant bool
  863. // wantPending follows the account pairing alone: the two icq->icq cases
  864. // differ only in preAuthorized yet expect the same value.
  865. wantPending bool
  866. }{
  867. {
  868. name: "no grant without preAuthorized",
  869. screenName: "100002",
  870. buddy: "100001",
  871. preAuthorized: "",
  872. wantGrant: false,
  873. wantPending: true,
  874. },
  875. {
  876. name: "icq->icq grant carries the authorization message",
  877. screenName: "100002",
  878. buddy: "100001",
  879. preAuthorized: "1",
  880. authMsg: "Hello! Please add me to your buddylist.",
  881. wantGrant: true,
  882. wantPending: true,
  883. },
  884. {
  885. // The grant is not gated on protocol: the store no-ops for a user who
  886. // cannot hold one, so an aim->aim add is granted the same way.
  887. name: "aim->aim is granted too",
  888. screenName: "mike",
  889. buddy: "joemama",
  890. preAuthorized: "1",
  891. wantGrant: true,
  892. wantPending: false,
  893. },
  894. {
  895. // The buddy is on the list either way. Reporting an error here would
  896. // only make the client retry an add that already succeeded.
  897. name: "a failed grant does not fail the add",
  898. screenName: "100002",
  899. buddy: "100001",
  900. preAuthorized: "1",
  901. grantErr: errors.New("record failed"),
  902. wantGrant: true,
  903. wantPending: true,
  904. },
  905. }
  906. for _, tt := range tests {
  907. t.Run(tt.name, func(t *testing.T) {
  908. sm := newMockSessionResolver(t)
  909. fs := newMockFeedbagService(t)
  910. blmFs := newMockFeedbagService(t)
  911. // The grant is recorded against the caller, so the instance has to
  912. // carry their identity.
  913. oscarSess := state.NewSession()
  914. oscarSess.SetIdentScreenName(state.NewIdentScreenName(tt.screenName))
  915. // UIN is stored separately from the screen name, so an ICQ session
  916. // reads as AIM until it is set.
  917. oscarSess.SetUIN(state.NewIdentScreenName(tt.screenName).UIN())
  918. session := &Session{
  919. AimSID: "sid",
  920. OSCARSession: oscarSess.AddInstance(),
  921. ScreenName: state.DisplayScreenName(tt.screenName),
  922. EventQueue: NewEventQueue(100),
  923. LastAccessed: time.Now(),
  924. }
  925. sm.EXPECT().GetSession(mock.Anything, "sid").Return(session, nil)
  926. sm.EXPECT().TouchSession(mock.Anything, "sid").Return(nil).Maybe()
  927. items := []wire.FeedbagItem{
  928. {GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  929. }
  930. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  931. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  932. // Capture the pending flag to confirm preAuthorized does not drive it.
  933. var sawPending, sawBuddyItem bool
  934. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  935. RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, upserted []wire.FeedbagItem) (*wire.SNACMessage, error) {
  936. for _, item := range upserted {
  937. if item.ClassID != wire.FeedbagClassIdBuddy {
  938. continue
  939. }
  940. sawBuddyItem = true
  941. if item.HasTag(wire.FeedbagAttributesPending) {
  942. sawPending = true
  943. }
  944. break
  945. }
  946. return nil, nil
  947. })
  948. // No expectation in the ungranted case: an unexpected call fails the
  949. // test, which is the assertion.
  950. var grants int
  951. var gotGrantor state.IdentScreenName
  952. var gotFrame wire.SNACFrame
  953. var gotBody wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy
  954. if tt.wantGrant {
  955. fs.EXPECT().PreAuthorizeBuddy(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  956. RunAndReturn(func(_ context.Context, instance *state.SessionInstance, frame wire.SNACFrame, body wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy) (*wire.SNACMessage, error) {
  957. grants++
  958. gotGrantor = instance.IdentScreenName()
  959. gotFrame = frame
  960. gotBody = body
  961. return nil, tt.grantErr
  962. }).Once()
  963. }
  964. h := &BuddyListHandler{
  965. FeedbagService: fs,
  966. BuddyListManager: &BuddyListManager{
  967. feedbagService: blmFs,
  968. iconSource: newTestIconSource(t),
  969. logger: slog.Default(),
  970. },
  971. Logger: slog.Default(),
  972. }
  973. query := "aimsid=sid&buddy=" + tt.buddy + "&group=Friends"
  974. if tt.preAuthorized != "" {
  975. query += "&preAuthorized=" + tt.preAuthorized
  976. }
  977. if tt.authMsg != "" {
  978. query += "&authorizationMsg=" + url.QueryEscape(tt.authMsg)
  979. }
  980. req := httptest.NewRequest(http.MethodGet, "/buddylist/addBuddy?"+query, nil)
  981. rr := httptest.NewRecorder()
  982. requireSession(sm, h.AddBuddy).ServeHTTP(rr, req)
  983. assert.Equal(t, http.StatusOK, rr.Code)
  984. // The handler claims no outcome either way; whether the item was
  985. // stored is conveyed by the async buddylist event, not this reply.
  986. assert.JSONEq(t, `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`, rr.Body.String())
  987. assert.True(t, sawBuddyItem, "expected a buddy item to be upserted")
  988. assert.Equal(t, tt.wantPending, sawPending,
  989. "the pending flag must follow the account pairing, not preAuthorized")
  990. if !tt.wantGrant {
  991. return
  992. }
  993. assert.Equal(t, 1, grants, "expected exactly one pre-authorization")
  994. assert.Equal(t, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagPreAuthorizeBuddy}, gotFrame)
  995. // The grant flows from the caller to the buddy they added: the buddy
  996. // may now add the caller back without a prompt.
  997. assert.Equal(t, state.NewIdentScreenName(tt.screenName), gotGrantor)
  998. assert.Equal(t, tt.buddy, gotBody.ScreenName)
  999. assert.Equal(t, tt.authMsg, gotBody.Message)
  1000. })
  1001. }
  1002. }
  1003. // icq->icq adds are marked pending up front so the item is stored either way:
  1004. // the decline a web client never sees would otherwise leave it holding a buddy
  1005. // no roster lists. The other pairings already store the item, where a stray flag
  1006. // would publish them as awaiting an authorization nobody requested.
  1007. func TestBuddyListHandler_AddBuddy_PendingFlagFollowsPairing(t *testing.T) {
  1008. tests := []struct {
  1009. name string
  1010. screenName string
  1011. buddy string
  1012. wantPending bool
  1013. }{
  1014. {
  1015. name: "icq->icq is marked pending",
  1016. screenName: "100002",
  1017. buddy: "100001",
  1018. wantPending: true,
  1019. },
  1020. {
  1021. name: "icq->aim is not",
  1022. screenName: "100002",
  1023. buddy: "mike",
  1024. },
  1025. {
  1026. name: "aim->icq is not; the feedbag service marks it itself",
  1027. screenName: "mike",
  1028. buddy: "100001",
  1029. },
  1030. {
  1031. name: "aim->aim is not",
  1032. screenName: "mike",
  1033. buddy: "joemama",
  1034. },
  1035. }
  1036. for _, tt := range tests {
  1037. t.Run(tt.name, func(t *testing.T) {
  1038. sm := newMockSessionResolver(t)
  1039. fs := newMockFeedbagService(t)
  1040. oscarSess := state.NewSession()
  1041. oscarSess.SetIdentScreenName(state.NewIdentScreenName(tt.screenName))
  1042. oscarSess.SetUIN(state.NewIdentScreenName(tt.screenName).UIN())
  1043. session := &Session{
  1044. AimSID: "sid",
  1045. OSCARSession: oscarSess.AddInstance(),
  1046. ScreenName: state.DisplayScreenName(tt.screenName),
  1047. EventQueue: NewEventQueue(100),
  1048. LastAccessed: time.Now(),
  1049. }
  1050. sm.EXPECT().GetSession(mock.Anything, "sid").Return(session, nil)
  1051. sm.EXPECT().TouchSession(mock.Anything, "sid").Return(nil).Maybe()
  1052. items := []wire.FeedbagItem{
  1053. {GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
  1054. }
  1055. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
  1056. Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil).Once()
  1057. var sawBuddyItem, sawPending bool
  1058. fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  1059. RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, upserted []wire.FeedbagItem) (*wire.SNACMessage, error) {
  1060. for _, item := range upserted {
  1061. if item.ClassID != wire.FeedbagClassIdBuddy {
  1062. continue
  1063. }
  1064. sawBuddyItem = true
  1065. if item.HasTag(wire.FeedbagAttributesPending) {
  1066. sawPending = true
  1067. }
  1068. }
  1069. return nil, nil
  1070. })
  1071. h := &BuddyListHandler{
  1072. FeedbagService: fs,
  1073. BuddyListManager: &BuddyListManager{
  1074. feedbagService: newMockFeedbagService(t),
  1075. iconSource: newTestIconSource(t),
  1076. logger: slog.Default(),
  1077. },
  1078. Logger: slog.Default(),
  1079. }
  1080. req := httptest.NewRequest(http.MethodGet,
  1081. "/buddylist/addBuddy?aimsid=sid&buddy="+tt.buddy+"&group=Friends", nil)
  1082. rr := httptest.NewRecorder()
  1083. requireSession(sm, h.AddBuddy).ServeHTTP(rr, req)
  1084. assert.Equal(t, http.StatusOK, rr.Code)
  1085. assert.True(t, sawBuddyItem, "expected a buddy item to be upserted")
  1086. assert.Equal(t, tt.wantPending, sawPending)
  1087. })
  1088. }
  1089. }