messaging_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. package handlers
  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/server/webapi/middleware"
  16. "github.com/mk6i/open-oscar-server/server/webapi/types"
  17. "github.com/mk6i/open-oscar-server/state"
  18. "github.com/mk6i/open-oscar-server/wire"
  19. )
  20. // requireSession wraps next with the session-resolving auth middleware for tests.
  21. func requireSession(sm middleware.WebAPISessionResolver, next func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
  22. return middleware.NewAuthMiddleware(nil, slog.Default()).RequireSession(sm, next)
  23. }
  24. // MockICBMService is a mock implementation of ICBMService
  25. type MockICBMService struct {
  26. mock.Mock
  27. }
  28. func (m *MockICBMService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error) {
  29. args := m.Called(ctx, instance, inFrame, inBody)
  30. if msg := args.Get(0); msg != nil {
  31. return msg.(*wire.SNACMessage), args.Error(1)
  32. }
  33. return nil, args.Error(1)
  34. }
  35. func (m *MockICBMService) ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error {
  36. args := m.Called(ctx, instance, inFrame, inBody)
  37. return args.Error(0)
  38. }
  39. // createTestSessionManager creates a WebAPISessionManager with a pre-populated session.
  40. func createTestSessionManager(screenName string) (*state.WebAPISessionManager, string) {
  41. return createTestSessionManagerWithOSCAR(screenName, nil)
  42. }
  43. // createTestSessionManagerWithOSCAR creates a WebAPISessionManager with an OSCAR session instance set.
  44. func createTestSessionManagerWithOSCAR(screenName string, oscarSession *state.SessionInstance) (*state.WebAPISessionManager, string) {
  45. mgr := state.NewWebAPISessionManager()
  46. session, _ := mgr.CreateSession(
  47. context.Background(),
  48. state.DisplayScreenName(screenName),
  49. "test-dev",
  50. []string{"im", "presence", "buddylist", "sentIM", "typing"},
  51. oscarSession,
  52. slog.Default(),
  53. )
  54. return mgr, session.AimSID
  55. }
  56. // stubLocateService answers UserInfoQuery with a reply carrying screenName, or
  57. // with an error when screenName is empty (i.e. the target is offline or blocked).
  58. func stubLocateService(screenName string) *MockLocateService {
  59. ls := &MockLocateService{}
  60. call := ls.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
  61. if screenName == "" {
  62. call.Return(wire.SNACMessage{}, io.EOF)
  63. } else {
  64. call.Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
  65. TLVUserInfo: wire.TLVUserInfo{ScreenName: screenName},
  66. }}, nil)
  67. }
  68. return ls
  69. }
  70. // stubFeedbagService answers Query with a single buddy item for buddy, carrying
  71. // alias when one is given.
  72. func stubFeedbagService(buddy, alias string) *MockFeedbagService {
  73. item := wire.FeedbagItem{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: buddy}
  74. if alias != "" {
  75. item.TLVLBlock = wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, alias)}}
  76. }
  77. fs := &MockFeedbagService{}
  78. fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(
  79. wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: []wire.FeedbagItem{item}}}, nil,
  80. )
  81. return fs
  82. }
  83. // sendIMForDest drives SendIM addressed to t, with the recipient's display name
  84. // resolving to locateName and the sender's alias for them set to alias, and returns
  85. // the events queued for the sender.
  86. func sendIMForDest(t *testing.T, dest, locateName, alias string) []types.Event {
  87. t.Helper()
  88. oscarInstance := state.NewSession().AddInstance()
  89. icbmService := &MockICBMService{}
  90. icbmService.On("ChannelMsgToHost", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
  91. Return(nil, nil)
  92. mgr := state.NewWebAPISessionManager()
  93. session, err := mgr.CreateSession(context.Background(), state.DisplayScreenName("Ann Dupree"),
  94. "test-dev", []string{"im", "sentIM", "conversation"}, oscarInstance, slog.Default())
  95. require.NoError(t, err)
  96. handler := &MessagingHandler{
  97. SessionManager: mgr,
  98. ICBMService: icbmService,
  99. LocateService: stubLocateService(locateName),
  100. FeedbagService: stubFeedbagService(dest, alias),
  101. Logger: slog.Default(),
  102. }
  103. // startSession wires this in production; SendIM reads aliases off the session.
  104. session.BuddyAliasLoader = func(ctx context.Context) (map[string]string, error) {
  105. return LookupBuddyAliases(ctx, handler.FeedbagService, session.OSCARSession)
  106. }
  107. req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+session.AimSID+"&t="+url.QueryEscape(dest)+"&message=hi", nil)
  108. require.NoError(t, err)
  109. rr := httptest.NewRecorder()
  110. requireSession(mgr, handler.SendIM).ServeHTTP(rr, req)
  111. require.Equal(t, http.StatusOK, rr.Code)
  112. return session.EventQueue.GetAllEvents()
  113. }
  114. // The client sends t as the normalized aimId, so the recipient's display name has
  115. // to come from the locate reply. Echoing t back as a displayId would overwrite the
  116. // properly formatted name the client already holds for that aimId.
  117. func TestMessagingHandler_SendIM_DestDisplayIDFromLocateReply(t *testing.T) {
  118. var sentIM types.SentIMEvent
  119. var conv map[string]interface{}
  120. for _, event := range sendIMForDest(t, "mikelee", "Mike Lee", "") {
  121. switch event.Type {
  122. case types.EventTypeSentIM:
  123. sentIM, _ = event.Data.(types.SentIMEvent)
  124. case types.EventTypeConversation:
  125. data, _ := event.Data.(map[string]interface{})
  126. convs, _ := data["conversations"].([]map[string]interface{})
  127. require.Len(t, convs, 1)
  128. conv = convs[0]
  129. }
  130. }
  131. assert.Equal(t, "anndupree", sentIM.Sender.AimID)
  132. assert.Equal(t, "Ann Dupree", sentIM.Sender.DisplayID)
  133. assert.Equal(t, "mikelee", sentIM.Dest.AimID)
  134. assert.Equal(t, "Mike Lee", sentIM.Dest.DisplayID)
  135. require.NotNil(t, conv)
  136. assert.Equal(t, "mikelee", conv["aimId"])
  137. assert.Equal(t, "Mike Lee", conv["displayId"])
  138. }
  139. // An alias is private to the sender and lives only in their feedbag, and the client
  140. // deletes the alias it holds every time it merges a user map. So the sentIM echo has
  141. // to repeat it, or messaging an aliased buddy renames him back to his screen name.
  142. func TestMessagingHandler_SendIM_DestCarriesAlias(t *testing.T) {
  143. var sentIM types.SentIMEvent
  144. for _, event := range sendIMForDest(t, "mikelee", "Mike Lee", "MICHAELLEE") {
  145. if event.Type == types.EventTypeSentIM {
  146. sentIM, _ = event.Data.(types.SentIMEvent)
  147. }
  148. }
  149. assert.Equal(t, "mikelee", sentIM.Dest.AimID)
  150. assert.Equal(t, "Mike Lee", sentIM.Dest.DisplayID)
  151. assert.Equal(t, "MICHAELLEE", sentIM.Dest.Friendly)
  152. }
  153. // When the recipient's display name cannot be resolved, displayId is omitted
  154. // rather than filled in with the aimId, leaving the client's existing name intact.
  155. func TestMessagingHandler_SendIM_OmitsDestDisplayIDWhenUnresolved(t *testing.T) {
  156. var sentIM types.SentIMEvent
  157. var conv map[string]interface{}
  158. for _, event := range sendIMForDest(t, "mikelee", "", "") {
  159. switch event.Type {
  160. case types.EventTypeSentIM:
  161. sentIM, _ = event.Data.(types.SentIMEvent)
  162. case types.EventTypeConversation:
  163. data, _ := event.Data.(map[string]interface{})
  164. convs, _ := data["conversations"].([]map[string]interface{})
  165. require.Len(t, convs, 1)
  166. conv = convs[0]
  167. }
  168. }
  169. assert.Equal(t, "mikelee", sentIM.Dest.AimID)
  170. assert.Empty(t, sentIM.Dest.DisplayID)
  171. encoded, err := json.Marshal(sentIM)
  172. require.NoError(t, err)
  173. assert.NotContains(t, string(encoded), "displayId\":\"mikelee\"")
  174. require.NotNil(t, conv)
  175. assert.Equal(t, "mikelee", conv["aimId"])
  176. assert.NotContains(t, conv, "displayId")
  177. }
  178. func TestMessagingHandler_SendIM(t *testing.T) {
  179. oscarInstance := state.NewSession().AddInstance()
  180. tests := []struct {
  181. name string
  182. queryParams string
  183. setupMocks func(*MockICBMService)
  184. expectedStatusCode int
  185. checkResponse func(*testing.T, string)
  186. }{
  187. {
  188. name: "Success",
  189. queryParams: "t=recipient&message=hello+world",
  190. setupMocks: func(is *MockICBMService) {
  191. is.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
  192. Return(nil, nil)
  193. },
  194. expectedStatusCode: http.StatusOK,
  195. checkResponse: func(t *testing.T, body string) {
  196. assert.Contains(t, body, `"statusCode":200`)
  197. assert.Contains(t, body, `"msgId"`)
  198. assert.Contains(t, body, `"state":"delivered"`)
  199. },
  200. },
  201. {
  202. name: "Error_MissingRecipient",
  203. queryParams: "message=hello",
  204. setupMocks: func(is *MockICBMService) {},
  205. expectedStatusCode: http.StatusBadRequest,
  206. checkResponse: func(t *testing.T, body string) {
  207. assert.Contains(t, body, "missing required parameter: t")
  208. },
  209. },
  210. {
  211. name: "Error_MissingMessage",
  212. queryParams: "t=recipient",
  213. setupMocks: func(is *MockICBMService) {},
  214. expectedStatusCode: http.StatusBadRequest,
  215. checkResponse: func(t *testing.T, body string) {
  216. assert.Contains(t, body, "missing required parameter: message")
  217. },
  218. },
  219. }
  220. for _, tt := range tests {
  221. t.Run(tt.name, func(t *testing.T) {
  222. icbmService := &MockICBMService{}
  223. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  224. handler := &MessagingHandler{
  225. SessionManager: sessionMgr,
  226. ICBMService: icbmService,
  227. LocateService: stubLocateService(""),
  228. FeedbagService: stubFeedbagService("someone", ""),
  229. Logger: slog.Default(),
  230. }
  231. tt.setupMocks(icbmService)
  232. reqURL := "/im/sendIM?aimsid=" + aimsid + "&" + tt.queryParams
  233. req, err := http.NewRequest("GET", reqURL, nil)
  234. assert.NoError(t, err)
  235. rr := httptest.NewRecorder()
  236. requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
  237. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  238. responseBody := strings.TrimSpace(rr.Body.String())
  239. if tt.checkResponse != nil {
  240. tt.checkResponse(t, responseBody)
  241. }
  242. icbmService.AssertExpectations(t)
  243. })
  244. }
  245. }
  246. func TestMessagingHandler_SendIM_POST(t *testing.T) {
  247. oscarInstance := state.NewSession().AddInstance()
  248. icbmService := &MockICBMService{}
  249. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  250. icbmService.On("ChannelMsgToHost", mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNACFrame"), mock.AnythingOfType("wire.SNAC_0x04_0x06_ICBMChannelMsgToHost")).
  251. Return(nil, nil)
  252. handler := &MessagingHandler{
  253. SessionManager: sessionMgr,
  254. ICBMService: icbmService,
  255. LocateService: stubLocateService(""),
  256. FeedbagService: stubFeedbagService("someone", ""),
  257. Logger: slog.Default(),
  258. }
  259. body := strings.NewReader("message=" + url.QueryEscape("hello from post"))
  260. req, err := http.NewRequest(http.MethodPost, "/im/sendIM?aimsid="+aimsid+"&f=json&t=recipient&r=1", body)
  261. assert.NoError(t, err)
  262. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  263. rr := httptest.NewRecorder()
  264. requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
  265. assert.Equal(t, http.StatusOK, rr.Code)
  266. assert.Contains(t, rr.Body.String(), `"msgId"`)
  267. icbmService.AssertExpectations(t)
  268. }
  269. func TestMessagingHandler_SendIM_MissingAimsid(t *testing.T) {
  270. handler := &MessagingHandler{
  271. SessionManager: state.NewWebAPISessionManager(),
  272. Logger: slog.Default(),
  273. }
  274. req, err := http.NewRequest("GET", "/im/sendIM", nil)
  275. assert.NoError(t, err)
  276. rr := httptest.NewRecorder()
  277. requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
  278. assert.Equal(t, http.StatusBadRequest, rr.Code)
  279. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  280. }
  281. func TestMessagingHandler_SendIM_InvalidSession(t *testing.T) {
  282. handler := &MessagingHandler{
  283. SessionManager: state.NewWebAPISessionManager(),
  284. Logger: slog.Default(),
  285. }
  286. req, err := http.NewRequest("GET", "/im/sendIM?aimsid=nonexistent&t=someone&message=hi", nil)
  287. assert.NoError(t, err)
  288. rr := httptest.NewRecorder()
  289. requireSession(handler.SessionManager, handler.SendIM).ServeHTTP(rr, req)
  290. assert.Equal(t, http.StatusUnauthorized, rr.Code)
  291. assert.Contains(t, rr.Body.String(), "invalid or expired session")
  292. }
  293. func TestMessagingHandler_SetTyping(t *testing.T) {
  294. oscarInstance := state.NewSession().AddInstance()
  295. tests := []struct {
  296. name string
  297. queryParams string
  298. setupMocks func(*MockICBMService)
  299. expectedStatusCode int
  300. checkResponse func(*testing.T, string)
  301. }{
  302. {
  303. name: "Success_TypingStarted",
  304. queryParams: "t=recipient&typingStatus=typing",
  305. setupMocks: func(is *MockICBMService) {
  306. is.On("ClientEvent", mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
  307. ChannelID: wire.ICBMChannelIM,
  308. ScreenName: "recipient",
  309. Event: 0x0002,
  310. }).Return(nil)
  311. },
  312. expectedStatusCode: http.StatusOK,
  313. checkResponse: func(t *testing.T, body string) {
  314. assert.Contains(t, body, `"statusCode":200`)
  315. },
  316. },
  317. {
  318. name: "Success_TypingPaused",
  319. queryParams: "t=recipient&typingStatus=typed",
  320. setupMocks: func(is *MockICBMService) {
  321. is.On("ClientEvent", mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
  322. ChannelID: wire.ICBMChannelIM,
  323. ScreenName: "recipient",
  324. Event: 0x0001,
  325. }).Return(nil)
  326. },
  327. expectedStatusCode: http.StatusOK,
  328. },
  329. {
  330. name: "Success_TypingStopped",
  331. queryParams: "t=recipient&typingStatus=none",
  332. setupMocks: func(is *MockICBMService) {
  333. is.On("ClientEvent", mock.Anything, oscarInstance, wire.SNACFrame{}, wire.SNAC_0x04_0x14_ICBMClientEvent{
  334. ChannelID: wire.ICBMChannelIM,
  335. ScreenName: "recipient",
  336. Event: 0x0000,
  337. }).Return(nil)
  338. },
  339. expectedStatusCode: http.StatusOK,
  340. },
  341. {
  342. name: "Error_MissingRecipient",
  343. queryParams: "typingStatus=typing",
  344. setupMocks: func(is *MockICBMService) {},
  345. expectedStatusCode: http.StatusBadRequest,
  346. checkResponse: func(t *testing.T, body string) {
  347. assert.Contains(t, body, "missing required parameter: t")
  348. },
  349. },
  350. }
  351. for _, tt := range tests {
  352. t.Run(tt.name, func(t *testing.T) {
  353. icbmService := &MockICBMService{}
  354. sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
  355. handler := &MessagingHandler{
  356. SessionManager: sessionMgr,
  357. ICBMService: icbmService,
  358. LocateService: stubLocateService(""),
  359. FeedbagService: stubFeedbagService("someone", ""),
  360. Logger: slog.Default(),
  361. }
  362. tt.setupMocks(icbmService)
  363. reqURL := "/im/setTyping?aimsid=" + aimsid + "&" + tt.queryParams
  364. req, err := http.NewRequest("GET", reqURL, nil)
  365. assert.NoError(t, err)
  366. rr := httptest.NewRecorder()
  367. requireSession(handler.SessionManager, handler.SetTyping).ServeHTTP(rr, req)
  368. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  369. if tt.checkResponse != nil {
  370. responseBody := strings.TrimSpace(rr.Body.String())
  371. tt.checkResponse(t, responseBody)
  372. }
  373. icbmService.AssertExpectations(t)
  374. })
  375. }
  376. }
  377. func TestMessagingHandler_SetTyping_MissingAimsid(t *testing.T) {
  378. handler := &MessagingHandler{
  379. SessionManager: state.NewWebAPISessionManager(),
  380. Logger: slog.Default(),
  381. }
  382. req, err := http.NewRequest("GET", "/im/setTyping", nil)
  383. assert.NoError(t, err)
  384. rr := httptest.NewRecorder()
  385. requireSession(handler.SessionManager, handler.SetTyping).ServeHTTP(rr, req)
  386. assert.Equal(t, http.StatusBadRequest, rr.Code)
  387. assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
  388. }