memberdir_test.go 12 KB

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