memberdir_handler_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. package webapi
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "log/slog"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/mock"
  13. "github.com/stretchr/testify/require"
  14. "github.com/mk6i/open-oscar-server/state"
  15. "github.com/mk6i/open-oscar-server/wire"
  16. )
  17. func searchReply(status uint16, results ...wire.TLVBlock) wire.SNACMessage {
  18. body := wire.SNAC_0x0F_0x03_InfoReply{Status: status}
  19. body.Results.List = results
  20. return wire.SNACMessage{Body: body}
  21. }
  22. func result(screenName, firstName, lastName string) wire.TLVBlock {
  23. return wire.TLVBlock{TLVList: wire.TLVList{
  24. wire.NewTLVBE(wire.ODirTLVScreenName, screenName),
  25. wire.NewTLVBE(wire.ODirTLVFirstName, firstName),
  26. wire.NewTLVBE(wire.ODirTLVLastName, lastName),
  27. }}
  28. }
  29. // decodeInfoArray pulls infoArray out of the response envelope at the given
  30. // path ("results.infoArray" for search, "infoArray" for get).
  31. func decodeInfoArray(t *testing.T, body []byte, nested bool) []MemberDirInfo {
  32. t.Helper()
  33. var envelope struct {
  34. Response struct {
  35. StatusCode int `json:"statusCode"`
  36. Data struct {
  37. InfoArray []MemberDirInfo `json:"infoArray"`
  38. Results struct {
  39. InfoArray []MemberDirInfo `json:"infoArray"`
  40. } `json:"results"`
  41. } `json:"data"`
  42. } `json:"response"`
  43. }
  44. require.NoError(t, json.Unmarshal(body, &envelope))
  45. assert.Equal(t, 200, envelope.Response.StatusCode)
  46. if nested {
  47. return envelope.Response.Data.Results.InfoArray
  48. }
  49. return envelope.Response.Data.InfoArray
  50. }
  51. func TestMemberDirHandler_Search_Keyword(t *testing.T) {
  52. dirSvc := newMockDirSearchService(t)
  53. // keyword=haha must map to the ODir interest TLV.
  54. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
  55. v, ok := q.String(wire.ODirTLVInterest)
  56. return ok && v == "haha"
  57. })).Return(searchReply(wire.ODirSearchResponseOK, result("FoundUser", "Found", "User")), nil)
  58. h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
  59. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  60. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dhaha&nToGet=200", nil)
  61. rr := httptest.NewRecorder()
  62. h.Search(rr, req, session)
  63. assert.Equal(t, http.StatusOK, rr.Code)
  64. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  65. require.Len(t, infoArray, 1)
  66. assert.Equal(t, "founduser", infoArray[0].Profile.AimID)
  67. assert.Equal(t, "FoundUser", infoArray[0].Profile.DisplayID)
  68. assert.Equal(t, "Found", infoArray[0].Profile.FirstName)
  69. }
  70. func TestMemberDirHandler_Search_FirstLastName(t *testing.T) {
  71. dirSvc := newMockDirSearchService(t)
  72. // firstName/lastName must map to the ODir name TLVs, not interest.
  73. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
  74. first, hasFirst := q.String(wire.ODirTLVFirstName)
  75. last, hasLast := q.String(wire.ODirTLVLastName)
  76. _, hasInterest := q.String(wire.ODirTLVInterest)
  77. return hasFirst && first == "Bob" && hasLast && last == "Smith" && !hasInterest
  78. })).Return(searchReply(wire.ODirSearchResponseOK, result("Bob", "Bob", "Smith")), nil)
  79. h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
  80. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  81. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=firstName%3DBob%2ClastName%3DSmith", nil)
  82. rr := httptest.NewRecorder()
  83. h.Search(rr, req, session)
  84. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  85. require.Len(t, infoArray, 1)
  86. assert.Equal(t, "bob", infoArray[0].Profile.AimID)
  87. }
  88. func TestMemberDirHandler_Search_ExcludesSelf(t *testing.T) {
  89. dirSvc := newMockDirSearchService(t)
  90. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).Return(
  91. searchReply(wire.ODirSearchResponseOK,
  92. result("Me", "", ""), // caller — must be filtered out
  93. result("Other", "", ""), // kept
  94. ), nil)
  95. h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
  96. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("M E")}
  97. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx", nil)
  98. rr := httptest.NewRecorder()
  99. h.Search(rr, req, session)
  100. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  101. require.Len(t, infoArray, 1)
  102. assert.Equal(t, "other", infoArray[0].Profile.AimID)
  103. }
  104. func TestMemberDirHandler_Search_RespectsJSONPCallback(t *testing.T) {
  105. dirSvc := newMockDirSearchService(t)
  106. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).Return(
  107. searchReply(wire.ODirSearchResponseOK), nil)
  108. h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
  109. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  110. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx&c=_callbacks_._abc", nil)
  111. rr := httptest.NewRecorder()
  112. h.Search(rr, req, session)
  113. // The web client loads this via a <script> tag, so the response must be
  114. // JavaScript (JSONP), not application/json — otherwise the browser CORB-blocks it.
  115. // The charset is explicit because a script tag otherwise decodes using the host
  116. // page's encoding, which mangles non-ASCII screen names.
  117. assert.Equal(t, "application/javascript; charset=utf-8", rr.Header().Get("Content-Type"))
  118. assert.Contains(t, rr.Body.String(), "_callbacks_._abc(")
  119. }
  120. func TestMemberDirHandler_Get_Self(t *testing.T) {
  121. reply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  122. reply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Me"))
  123. reply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Myself"))
  124. locSvc := newMockLocateService(t)
  125. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
  126. return q.ScreenName == "me"
  127. })).Return(wire.SNACMessage{Body: reply}, nil)
  128. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  129. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  130. // No "t" param: defaults to self.
  131. req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid", nil)
  132. rr := httptest.NewRecorder()
  133. h.Get(rr, req, session)
  134. infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
  135. require.Len(t, infoArray, 1)
  136. assert.Equal(t, "me", infoArray[0].Profile.AimID)
  137. assert.Equal(t, "Me", infoArray[0].Profile.FirstName)
  138. assert.Equal(t, "Myself", infoArray[0].Profile.LastName)
  139. }
  140. func TestMemberDirHandler_Get_LabelsEachTargetWithOwnIdentity(t *testing.T) {
  141. dirReply := func(firstName string) wire.SNACMessage {
  142. reply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  143. reply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, firstName))
  144. return wire.SNACMessage{Body: reply}
  145. }
  146. locSvc := newMockLocateService(t)
  147. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
  148. return q.ScreenName == "Bob Smith"
  149. })).Return(dirReply("Bob"), nil)
  150. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
  151. return q.ScreenName == "alice"
  152. })).Return(dirReply("Alice"), nil)
  153. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  154. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("Bob Smith")}
  155. req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid&t=Bob+Smith,alice", nil)
  156. rr := httptest.NewRecorder()
  157. h.Get(rr, req, session)
  158. // Each result carries the identity of the target it describes, not the
  159. // caller's — the client keys users by aimId.
  160. infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
  161. require.Len(t, infoArray, 2)
  162. assert.Equal(t, "bobsmith", infoArray[0].Profile.AimID)
  163. assert.Equal(t, "Bob Smith", infoArray[0].Profile.DisplayID)
  164. assert.Equal(t, "Bob", infoArray[0].Profile.FirstName)
  165. assert.Equal(t, "alice", infoArray[1].Profile.AimID)
  166. assert.Equal(t, "alice", infoArray[1].Profile.DisplayID)
  167. assert.Equal(t, "Alice", infoArray[1].Profile.FirstName)
  168. }
  169. func TestMemberDirHandler_Get_CapsTargetFanOut(t *testing.T) {
  170. locSvc := newMockLocateService(t)
  171. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  172. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil).
  173. Times(maxMemberDirTargets)
  174. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  175. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  176. // Every target costs a directory lookup, so an arbitrarily long "t" list
  177. // must not translate into an unbounded number of them.
  178. targets := make([]string, maxMemberDirTargets+50)
  179. for i := range targets {
  180. targets[i] = fmt.Sprintf("user%d", i)
  181. }
  182. req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid&t="+strings.Join(targets, ","), nil)
  183. rr := httptest.NewRecorder()
  184. h.Get(rr, req, session)
  185. infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
  186. assert.Len(t, infoArray, maxMemberDirTargets)
  187. }
  188. func TestMemberDirHandler_Update_PersistsNameAndPreservesOtherFields(t *testing.T) {
  189. // Current directory record has a city set that the name form must not wipe.
  190. current := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  191. current.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Old"))
  192. current.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Name"))
  193. current.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
  194. locSvc := newMockLocateService(t)
  195. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  196. Return(wire.SNACMessage{Body: current}, nil)
  197. // The set request must carry the new name AND the preserved city.
  198. locSvc.EXPECT().SetDirInfo(mock.Anything, mock.Anything, mock.Anything,
  199. mock.MatchedBy(func(b wire.SNAC_0x02_0x09_LocateSetDirInfo) bool {
  200. first, _ := b.String(wire.ODirTLVFirstName)
  201. last, _ := b.String(wire.ODirTLVLastName)
  202. city, _ := b.String(wire.ODirTLVCity)
  203. return first == "Mike" && last == "K" && city == "Reno"
  204. })).Return(wire.SNACMessage{}, nil)
  205. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  206. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
  207. req := httptest.NewRequest("GET",
  208. "/memberDir/update?aimsid=sid&set=firstName%3DMike&set=lastName%3DK&set=hideLevel%3DemailsAndCellular", nil)
  209. rr := httptest.NewRecorder()
  210. h.Update(rr, req, session)
  211. assert.Equal(t, http.StatusOK, rr.Code)
  212. }
  213. func TestMemberDirHandler_Update_AbortsWhenCurrentInfoUnreadable(t *testing.T) {
  214. locSvc := newMockLocateService(t)
  215. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  216. Return(wire.SNACMessage{}, io.ErrUnexpectedEOF)
  217. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  218. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
  219. req := httptest.NewRequest("GET", "/memberDir/update?aimsid=sid&set=firstName%3DMike&set=lastName%3DK", nil)
  220. rr := httptest.NewRecorder()
  221. h.Update(rr, req, session)
  222. // SetDirectoryInfo replaces every column, so writing a record we couldn't
  223. // seed would blank the fields this form doesn't edit. Report the failure
  224. // instead.
  225. locSvc.AssertNotCalled(t, "SetDirInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
  226. var envelope struct {
  227. Response struct {
  228. StatusCode int `json:"statusCode"`
  229. } `json:"response"`
  230. }
  231. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
  232. assert.Equal(t, http.StatusInternalServerError, envelope.Response.StatusCode)
  233. }