im_handler_test.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. package webapi
  2. import (
  3. "context"
  4. "encoding/json"
  5. "io"
  6. "log/slog"
  7. "net/http"
  8. "net/http/httptest"
  9. "net/url"
  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. // requireSession wraps next with the session-resolving auth middleware for tests.
  19. func requireSession(sm SessionResolver, next func(http.ResponseWriter, *http.Request, *Session)) http.Handler {
  20. return NewAuthMiddleware(slog.Default()).RequireSession(sm, next)
  21. }
  22. // createTestSessionManager creates a SessionManager with a pre-populated session.
  23. // createTestSessionManagerWithOSCAR creates a SessionManager with an OSCAR session instance set.
  24. func createTestSessionManagerWithOSCAR(screenName string, oscarSession *state.SessionInstance) (*SessionManager, string) {
  25. mgr := NewSessionManager()
  26. session, _ := mgr.CreateSession(state.DisplayScreenName(screenName), []string{"im", "presence", "buddylist", "sentIM", "typing"}, oscarSession, "", slog.Default())
  27. session.FeedbagLoader = emptyFeedbagLoader
  28. return mgr, session.AimSID
  29. }
  30. // stubLocateService answers UserInfoQuery with a reply carrying screenName, or
  31. // with an error when screenName is empty (i.e. the target is offline or blocked).
  32. func stubLocateService(t *testing.T, screenName string) *mockLocateService {
  33. ls := newMockLocateService(t)
  34. call := ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything)
  35. if screenName == "" {
  36. call.Return(wire.SNACMessage{}, io.EOF).Maybe()
  37. } else {
  38. call.Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  39. TLVUserInfo: wire.TLVUserInfo{ScreenName: screenName},
  40. }}, nil).Maybe()
  41. }
  42. return ls
  43. }
  44. // stubFeedbagService answers Query with a single buddy item for buddy, carrying
  45. // alias when one is given.
  46. func stubFeedbagService(t *testing.T, buddy, alias string) *mockFeedbagService {
  47. item := wire.FeedbagItem{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: buddy}
  48. if alias != "" {
  49. item.TLVLBlock = wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, alias)}}
  50. }
  51. fs := newMockFeedbagService(t)
  52. fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).Return(
  53. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: []wire.FeedbagItem{item}}}, nil,
  54. ).Maybe()
  55. return fs
  56. }
  57. // sendIMForDest drives SendIM addressed to t, with the recipient's display name
  58. // resolving to locateName and the sender's alias for them set to alias, and returns
  59. // the events queued for the sender.
  60. func sendIMForDest(t *testing.T, dest, locateName, alias string) []Event {
  61. t.Helper()
  62. oscarInstance := state.NewSession().AddInstance()
  63. icbmService := newMockICBMService(t)
  64. icbmService.EXPECT().ChannelMsgToHost(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  65. Return(nil, nil)
  66. mgr := NewSessionManager()
  67. session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"), []string{"im", "sentIM", "conversation"}, oscarInstance, "", slog.Default())
  68. require.NoError(t, err)
  69. session.FeedbagLoader = emptyFeedbagLoader
  70. handler := &MessagingHandler{
  71. ICBMService: icbmService,
  72. LocateService: stubLocateService(t, locateName),
  73. FeedbagService: stubFeedbagService(t, dest, alias),
  74. Logger: slog.Default(),
  75. }
  76. // startSession wires this in production; SendIM reads aliases off the session.
  77. session.FeedbagLoader = feedbagServiceLoader(handler.FeedbagService, session.OSCARSession)
  78. req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+session.AimSID+"&t="+url.QueryEscape(dest)+"&message=hi", nil)
  79. require.NoError(t, err)
  80. rr := httptest.NewRecorder()
  81. requireSession(mgr, handler.SendIM).ServeHTTP(rr, req)
  82. require.Equal(t, http.StatusOK, rr.Code)
  83. return session.EventQueue.GetAllEvents()
  84. }
  85. // The client sends t as the normalized aimId, so the recipient's display name has
  86. // to come from the locate reply. Echoing t back as a displayId would overwrite the
  87. // properly formatted name the client already holds for that aimId.
  88. func TestMessagingHandler_SendIM_DestDisplayIDFromLocateReply(t *testing.T) {
  89. var sentIM SentIMEvent
  90. var conv ConversationEntryData
  91. for _, event := range sendIMForDest(t, "mikelee", "Mike Lee", "") {
  92. switch event.Type {
  93. case EventTypeSentIM:
  94. sentIM, _ = event.Data.(SentIMEvent)
  95. case EventTypeConversation:
  96. data, _ := event.Data.(*ConversationData)
  97. require.NotNil(t, data)
  98. require.Len(t, data.Conversations, 1)
  99. conv = data.Conversations[0]
  100. }
  101. }
  102. assert.Equal(t, "anndupree", sentIM.Sender.AimID)
  103. assert.Equal(t, "Ann Dupree", sentIM.Sender.DisplayID)
  104. assert.Equal(t, "mikelee", sentIM.Dest.AimID)
  105. assert.Equal(t, "Mike Lee", sentIM.Dest.DisplayID)
  106. assert.True(t, sentIM.ISent, "the sender's own echo must be marked iSent")
  107. assert.Equal(t, "mikelee", conv.AimID)
  108. assert.Equal(t, "Mike Lee", conv.DisplayID)
  109. }
  110. // An alias is private to the sender and lives only in their feedbag, and the client
  111. // deletes the alias it holds every time it merges a user map. So the sentIM echo has
  112. // to repeat it, or messaging an aliased buddy renames him back to his screen name.
  113. func TestMessagingHandler_SendIM_DestCarriesAlias(t *testing.T) {
  114. var sentIM SentIMEvent
  115. for _, event := range sendIMForDest(t, "mikelee", "Mike Lee", "MICHAELLEE") {
  116. if event.Type == EventTypeSentIM {
  117. sentIM, _ = event.Data.(SentIMEvent)
  118. }
  119. }
  120. assert.Equal(t, "mikelee", sentIM.Dest.AimID)
  121. assert.Equal(t, "Mike Lee", sentIM.Dest.DisplayID)
  122. assert.Equal(t, "MICHAELLEE", sentIM.Dest.Friendly)
  123. }
  124. // When the recipient's display name cannot be resolved, displayId is omitted
  125. // rather than filled in with the aimId, leaving the client's existing name intact.
  126. func TestMessagingHandler_SendIM_OmitsDestDisplayIDWhenUnresolved(t *testing.T) {
  127. var sentIM SentIMEvent
  128. var conv ConversationEntryData
  129. for _, event := range sendIMForDest(t, "mikelee", "", "") {
  130. switch event.Type {
  131. case EventTypeSentIM:
  132. sentIM, _ = event.Data.(SentIMEvent)
  133. case EventTypeConversation:
  134. data, _ := event.Data.(*ConversationData)
  135. require.NotNil(t, data)
  136. require.Len(t, data.Conversations, 1)
  137. conv = data.Conversations[0]
  138. }
  139. }
  140. assert.Equal(t, "mikelee", sentIM.Dest.AimID)
  141. assert.Empty(t, sentIM.Dest.DisplayID)
  142. encoded, err := json.Marshal(sentIM)
  143. require.NoError(t, err)
  144. assert.NotContains(t, string(encoded), "displayId\":\"mikelee\"")
  145. assert.Equal(t, "mikelee", conv.AimID)
  146. assert.Empty(t, conv.DisplayID)
  147. // omitempty is what keeps it out of the payload.
  148. encodedConv, err := json.Marshal(conv)
  149. require.NoError(t, err)
  150. assert.NotContains(t, string(encodedConv), "displayId")
  151. }
  152. func TestMessagingHandler_SendIM(t *testing.T) {
  153. oscarInstance := state.NewSession().AddInstance()
  154. tests := []struct {
  155. name string
  156. queryParams string
  157. setupMocks func(*mockICBMService)
  158. expectedStatusCode int
  159. checkResponse func(*testing.T, string)
  160. }{
  161. {
  162. name: "Success",
  163. queryParams: "t=recipient&message=hello+world",
  164. setupMocks: func(is *mockICBMService) {
  165. is.EXPECT().ChannelMsgToHost(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
  166. Return(nil, nil)
  167. },
  168. expectedStatusCode: http.StatusOK,
  169. checkResponse: func(t *testing.T, body string) {
  170. assert.Contains(t, body, `"statusCode":200`)
  171. assert.Contains(t, body, `"msgId"`)
  172. assert.Contains(t, body, `"state":"delivered"`)
  173. assert.Contains(t, body, `"timestamp":`)
  174. },
  175. },
  176. {
  177. name: "Error_MissingRecipient",
  178. queryParams: "message=hello",
  179. setupMocks: func(is *mockICBMService) {},
  180. expectedStatusCode: http.StatusBadRequest,
  181. checkResponse: func(t *testing.T, body string) {
  182. assert.Contains(t, body, "missing required parameter: t")
  183. },
  184. },
  185. {
  186. name: "Error_MissingMessage",
  187. queryParams: "t=recipient",
  188. setupMocks: func(is *mockICBMService) {},
  189. expectedStatusCode: http.StatusBadRequest,
  190. checkResponse: func(t *testing.T, body string) {
  191. assert.Contains(t, body, "missing required parameter: message")
  192. },
  193. },
  194. }
  195. for _, tt := range tests {
  196. t.Run(tt.name, func(t *testing.T) {
  197. icbmService := newMockICBMService(t)
  198. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  199. handler := &MessagingHandler{
  200. ICBMService: icbmService,
  201. LocateService: stubLocateService(t, ""),
  202. FeedbagService: stubFeedbagService(t, "someone", ""),
  203. Logger: slog.Default(),
  204. }
  205. tt.setupMocks(icbmService)
  206. reqURL := "/im/sendIM?aimsid=" + aimsid + "&" + tt.queryParams
  207. req, err := http.NewRequest("GET", reqURL, nil)
  208. assert.NoError(t, err)
  209. rr := httptest.NewRecorder()
  210. requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
  211. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  212. responseBody := strings.TrimSpace(rr.Body.String())
  213. if tt.checkResponse != nil {
  214. tt.checkResponse(t, responseBody)
  215. }
  216. })
  217. }
  218. }
  219. // The ICBM service refuses to store a message for an offline recipient unless the
  220. // store directive is present, so the client's offlineIM flag has to become one.
  221. func TestMessagingHandler_SendIM_OfflineIMSetsStoreTLV(t *testing.T) {
  222. oscarInstance := state.NewSession().AddInstance()
  223. icbmService := newMockICBMService(t)
  224. var sent wire.SNAC_0x04_0x06_ICBMChannelMsgToHost
  225. icbmService.EXPECT().ChannelMsgToHost(mock.Anything, oscarInstance, mock.Anything, mock.Anything).
  226. Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) {
  227. sent = inBody
  228. }).
  229. Return(nil, nil)
  230. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  231. handler := &MessagingHandler{
  232. ICBMService: icbmService,
  233. LocateService: stubLocateService(t, ""),
  234. FeedbagService: stubFeedbagService(t, "recipient", ""),
  235. Logger: slog.Default(),
  236. }
  237. req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+aimsid+"&t=recipient&message=hi&offlineIM=true", nil)
  238. require.NoError(t, err)
  239. rr := httptest.NewRecorder()
  240. requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
  241. require.Equal(t, http.StatusOK, rr.Code)
  242. _, hasStore := sent.Bytes(wire.ICBMTLVStore)
  243. assert.True(t, hasStore)
  244. }
  245. // An undeliverable IM must still produce an envelope: the web client reads
  246. // response.statusCode, and an empty body leaves it with no status at all.
  247. func TestMessagingHandler_SendIM_UndeliverableReportsStatus(t *testing.T) {
  248. tests := []struct {
  249. name string
  250. errs wire.SNACError
  251. }{
  252. {
  253. name: "RecipientOffline",
  254. errs: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn},
  255. },
  256. {
  257. name: "OfflineInboxFull",
  258. errs: wire.SNACError{
  259. Code: wire.ErrorCodeNotLoggedOn,
  260. TLVRestBlock: wire.TLVRestBlock{TLVList: wire.TLVList{
  261. wire.NewTLVBE(wire.ErrorTLVErrorSubcode, wire.ICBMSubErrOfflineIMExceedMax),
  262. }},
  263. },
  264. },
  265. {
  266. name: "SenderBlockedRecipient",
  267. errs: wire.SNACError{Code: wire.ErrorCodeInLocalPermitDeny},
  268. },
  269. }
  270. for _, tt := range tests {
  271. t.Run(tt.name, func(t *testing.T) {
  272. oscarInstance := state.NewSession().AddInstance()
  273. icbmService := newMockICBMService(t)
  274. icbmService.EXPECT().ChannelMsgToHost(mock.Anything, oscarInstance, mock.Anything, mock.Anything).
  275. Return(&wire.SNACMessage{
  276. Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMErr},
  277. Body: tt.errs,
  278. }, nil)
  279. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  280. handler := &MessagingHandler{
  281. ICBMService: icbmService,
  282. LocateService: stubLocateService(t, ""),
  283. FeedbagService: stubFeedbagService(t, "recipient", ""),
  284. Logger: slog.Default(),
  285. }
  286. req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+aimsid+"&f=json&t=recipient&message=hi", nil)
  287. require.NoError(t, err)
  288. rr := httptest.NewRecorder()
  289. requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
  290. require.Equal(t, http.StatusOK, rr.Code)
  291. assert.Contains(t, rr.Body.String(), `"statusCode":602`)
  292. assert.NotContains(t, rr.Body.String(), `"msgId"`)
  293. })
  294. }
  295. }
  296. func TestMessagingHandler_SendIM_POST(t *testing.T) {
  297. oscarInstance := state.NewSession().AddInstance()
  298. icbmService := newMockICBMService(t)
  299. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  300. icbmService.EXPECT().ChannelMsgToHost(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
  301. Return(nil, nil)
  302. handler := &MessagingHandler{
  303. ICBMService: icbmService,
  304. LocateService: stubLocateService(t, ""),
  305. FeedbagService: stubFeedbagService(t, "someone", ""),
  306. Logger: slog.Default(),
  307. }
  308. body := strings.NewReader("message=" + url.QueryEscape("hello from post"))
  309. req, err := http.NewRequest(http.MethodPost, "/im/sendIM?aimsid="+aimsid+"&f=json&t=recipient&r=1", body)
  310. assert.NoError(t, err)
  311. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  312. rr := httptest.NewRecorder()
  313. requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
  314. assert.Equal(t, http.StatusOK, rr.Code)
  315. assert.Contains(t, rr.Body.String(), `"msgId"`)
  316. }
  317. func TestMessagingHandler_SendIM_MissingAimsid(t *testing.T) {
  318. sessionMgr := NewSessionManager()
  319. handler := &MessagingHandler{
  320. Logger: slog.Default(),
  321. }
  322. req, err := http.NewRequest("GET", "/im/sendIM", nil)
  323. assert.NoError(t, err)
  324. rr := httptest.NewRecorder()
  325. requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
  326. assert.Equal(t, http.StatusBadRequest, rr.Code)
  327. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  328. }
  329. func TestMessagingHandler_SendIM_InvalidSession(t *testing.T) {
  330. sessionMgr := NewSessionManager()
  331. handler := &MessagingHandler{
  332. Logger: slog.Default(),
  333. }
  334. req, err := http.NewRequest("GET", "/im/sendIM?aimsid=nonexistent&t=someone&message=hi", nil)
  335. assert.NoError(t, err)
  336. rr := httptest.NewRecorder()
  337. requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
  338. assert.Equal(t, http.StatusUnauthorized, rr.Code)
  339. assert.Contains(t, rr.Body.String(), "invalid or expired session")
  340. }
  341. func TestMessagingHandler_SetTyping(t *testing.T) {
  342. oscarInstance := state.NewSession().AddInstance()
  343. tests := []struct {
  344. name string
  345. queryParams string
  346. setupMocks func(*mockICBMService)
  347. expectedStatusCode int
  348. checkResponse func(*testing.T, string)
  349. }{
  350. {
  351. name: "Success_TypingStarted",
  352. queryParams: "t=recipient&typingStatus=typing",
  353. setupMocks: func(is *mockICBMService) {
  354. is.EXPECT().ClientEvent(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
  355. ChannelID: wire.ICBMChannelIM,
  356. ScreenName: "recipient",
  357. Event: 0x0002,
  358. }).Return(nil)
  359. },
  360. expectedStatusCode: http.StatusOK,
  361. checkResponse: func(t *testing.T, body string) {
  362. assert.Contains(t, body, `"statusCode":200`)
  363. },
  364. },
  365. {
  366. name: "Success_TypingPaused",
  367. queryParams: "t=recipient&typingStatus=typed",
  368. setupMocks: func(is *mockICBMService) {
  369. is.EXPECT().ClientEvent(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
  370. ChannelID: wire.ICBMChannelIM,
  371. ScreenName: "recipient",
  372. Event: 0x0001,
  373. }).Return(nil)
  374. },
  375. expectedStatusCode: http.StatusOK,
  376. },
  377. {
  378. name: "Success_TypingStopped",
  379. queryParams: "t=recipient&typingStatus=none",
  380. setupMocks: func(is *mockICBMService) {
  381. is.EXPECT().ClientEvent(mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
  382. ChannelID: wire.ICBMChannelIM,
  383. ScreenName: "recipient",
  384. Event: 0x0000,
  385. }).Return(nil)
  386. },
  387. expectedStatusCode: http.StatusOK,
  388. },
  389. {
  390. name: "Error_MissingRecipient",
  391. queryParams: "typingStatus=typing",
  392. setupMocks: func(is *mockICBMService) {},
  393. expectedStatusCode: http.StatusBadRequest,
  394. checkResponse: func(t *testing.T, body string) {
  395. assert.Contains(t, body, "missing required parameter: t")
  396. },
  397. },
  398. }
  399. for _, tt := range tests {
  400. t.Run(tt.name, func(t *testing.T) {
  401. icbmService := newMockICBMService(t)
  402. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  403. handler := &MessagingHandler{
  404. ICBMService: icbmService,
  405. LocateService: stubLocateService(t, ""),
  406. FeedbagService: stubFeedbagService(t, "someone", ""),
  407. Logger: slog.Default(),
  408. }
  409. tt.setupMocks(icbmService)
  410. reqURL := "/im/setTyping?aimsid=" + aimsid + "&" + tt.queryParams
  411. req, err := http.NewRequest("GET", reqURL, nil)
  412. assert.NoError(t, err)
  413. rr := httptest.NewRecorder()
  414. requireSession(sessionMgr, handler.SetTyping).ServeHTTP(rr, req)
  415. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  416. if tt.checkResponse != nil {
  417. responseBody := strings.TrimSpace(rr.Body.String())
  418. tt.checkResponse(t, responseBody)
  419. }
  420. })
  421. }
  422. }
  423. func TestMessagingHandler_SetTyping_MissingAimsid(t *testing.T) {
  424. sessionMgr := NewSessionManager()
  425. handler := &MessagingHandler{
  426. Logger: slog.Default(),
  427. }
  428. req, err := http.NewRequest("GET", "/im/setTyping", nil)
  429. assert.NoError(t, err)
  430. rr := httptest.NewRecorder()
  431. requireSession(sessionMgr, handler.SetTyping).ServeHTTP(rr, req)
  432. assert.Equal(t, http.StatusBadRequest, rr.Code)
  433. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  434. }
  435. // ChannelMsgToHost delivers before it returns, so an ICBMClientErr can reach the pump
  436. // while SendIM is still inside that call. Recording the cookie->msgId mapping after
  437. // the send loses that race and emits a clientError naming no message. The interleaving
  438. // is driven deterministically by handling the error SNAC from inside the mock.
  439. func TestMessagingHandler_SendIM_ClientErrorDuringSendNamesTheMessage(t *testing.T) {
  440. oscarInstance := state.NewSession().AddInstance()
  441. icbmService := newMockICBMService(t)
  442. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  443. sess, err := sessionMgr.GetSession(context.Background(), aimsid)
  444. require.NoError(t, err)
  445. icbmService.EXPECT().
  446. ChannelMsgToHost(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
  447. Run(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) {
  448. sess.handleSNACMessage(wire.SNACMessage{
  449. Frame: wire.SNACFrame{FoodGroup: wire.ICBM, SubGroup: wire.ICBMClientErr},
  450. Body: wire.SNAC_0x04_0x0B_ICBMClientErr{
  451. Cookie: inBody.Cookie,
  452. ChannelID: wire.ICBMChannelIM,
  453. ScreenName: "Recipient",
  454. },
  455. })
  456. }).
  457. Return(nil, nil)
  458. handler := &MessagingHandler{
  459. ICBMService: icbmService,
  460. LocateService: stubLocateService(t, ""),
  461. FeedbagService: stubFeedbagService(t, "someone", ""),
  462. Logger: slog.Default(),
  463. }
  464. req, reqErr := http.NewRequest("GET", "/im/sendIM?aimsid="+aimsid+"&t=recipient&message=hello", nil)
  465. require.NoError(t, reqErr)
  466. rr := httptest.NewRecorder()
  467. requireSession(sessionMgr, handler.SendIM).ServeHTTP(rr, req)
  468. require.Equal(t, http.StatusOK, rr.Code)
  469. var sentMsgID string
  470. var clientErr ClientErrorEvent
  471. for _, event := range sess.EventQueue.GetAllEvents() {
  472. switch event.Type {
  473. case EventTypeSentIM:
  474. if e, ok := event.Data.(SentIMEvent); ok {
  475. sentMsgID = e.MsgID
  476. }
  477. case EventTypeClientError:
  478. clientErr, _ = event.Data.(ClientErrorEvent)
  479. }
  480. }
  481. // The cookie the client is told about must be the msgId it was handed for the
  482. // very message that failed.
  483. require.NotEmpty(t, sentMsgID)
  484. assert.Equal(t, sentMsgID, clientErr.Cookie)
  485. assert.Equal(t, "recipient", clientErr.Source.AimID)
  486. }
  487. // The sentIM echo is merged onto both parties' user objects, and sending a
  488. // message changes nobody's presence.
  489. func TestMessagingHandler_SendIM_EchoesRealStates(t *testing.T) {
  490. // sendIM drives SendIM from a sender whose own OSCAR session is configured by
  491. // configSender, with recipientPresence seeded into the session's view.
  492. sendIM := func(t *testing.T, configSender func(*state.SessionInstance), recipientPresence *wire.TLVUserInfo) SentIMEvent {
  493. t.Helper()
  494. oscarInstance := state.NewSession().AddInstance()
  495. configSender(oscarInstance)
  496. icbmService := newMockICBMService(t)
  497. icbmService.EXPECT().ChannelMsgToHost(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  498. Return(nil, nil)
  499. mgr := NewSessionManager()
  500. session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"),
  501. []string{"im", "sentIM"}, oscarInstance, "", slog.Default())
  502. require.NoError(t, err)
  503. session.FeedbagLoader = emptyFeedbagLoader
  504. if recipientPresence != nil {
  505. buddyArrives(session, *recipientPresence)
  506. }
  507. handler := &MessagingHandler{
  508. ICBMService: icbmService,
  509. LocateService: stubLocateService(t, "Mike Kelly"),
  510. FeedbagService: stubFeedbagService(t, "mikekelly", ""),
  511. Logger: slog.Default(),
  512. }
  513. session.FeedbagLoader = feedbagServiceLoader(handler.FeedbagService, session.OSCARSession)
  514. req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+session.AimSID+"&t=mikekelly&message=hi", nil)
  515. require.NoError(t, err)
  516. rr := httptest.NewRecorder()
  517. requireSession(mgr, handler.SendIM).ServeHTTP(rr, req)
  518. require.Equal(t, http.StatusOK, rr.Code)
  519. for _, event := range session.EventQueue.GetAllEvents() {
  520. if sent, ok := event.Data.(SentIMEvent); ok {
  521. return sent
  522. }
  523. }
  524. t.Fatal("no sentIM event was pushed")
  525. return SentIMEvent{}
  526. }
  527. online := func(*state.SessionInstance) {}
  528. awayRecipient := func() *wire.TLVUserInfo {
  529. info := wire.TLVUserInfo{ScreenName: "Mike Kelly"}
  530. info.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
  531. return &info
  532. }
  533. t.Run("the recipient's state comes from the presence view", func(t *testing.T) {
  534. got := sendIM(t, online, awayRecipient())
  535. assert.Equal(t, "away", got.Dest.State)
  536. })
  537. t.Run("a recipient with no presence carries no state", func(t *testing.T) {
  538. got := sendIM(t, online, nil)
  539. assert.Empty(t, got.Dest.State)
  540. })
  541. t.Run("the sender's own away state survives sending", func(t *testing.T) {
  542. got := sendIM(t, func(instance *state.SessionInstance) {
  543. instance.SetUserInfoFlag(wire.OServiceUserFlagUnavailable)
  544. }, nil)
  545. assert.Equal(t, "away", got.Sender.State)
  546. })
  547. t.Run("an online sender reports online", func(t *testing.T) {
  548. got := sendIM(t, online, nil)
  549. assert.Equal(t, "online", got.Sender.State)
  550. })
  551. }