memberdir_handler_test.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. package webapi
  2. import (
  3. "encoding/json"
  4. "fmt"
  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. func searchReply(status uint16, results ...wire.TLVBlock) wire.SNACMessage {
  19. body := wire.SNAC_0x0F_0x03_InfoReply{Status: status}
  20. body.Results.List = results
  21. return wire.SNACMessage{Body: body}
  22. }
  23. func result(screenName, firstName, lastName string) wire.TLVBlock {
  24. return wire.TLVBlock{TLVList: wire.TLVList{
  25. wire.NewTLVBE(wire.ODirTLVScreenName, screenName),
  26. wire.NewTLVBE(wire.ODirTLVFirstName, firstName),
  27. wire.NewTLVBE(wire.ODirTLVLastName, lastName),
  28. }}
  29. }
  30. // decodeInfoArray pulls infoArray out of the response envelope at the given
  31. // path ("results.infoArray" for search, "infoArray" for get).
  32. func decodeInfoArray(t *testing.T, body []byte, nested bool) []MemberDirInfo {
  33. t.Helper()
  34. var envelope struct {
  35. Response struct {
  36. StatusCode int `json:"statusCode"`
  37. Data struct {
  38. InfoArray []MemberDirInfo `json:"infoArray"`
  39. Results struct {
  40. InfoArray []MemberDirInfo `json:"infoArray"`
  41. } `json:"results"`
  42. } `json:"data"`
  43. } `json:"response"`
  44. }
  45. require.NoError(t, json.Unmarshal(body, &envelope))
  46. assert.Equal(t, 200, envelope.Response.StatusCode)
  47. if nested {
  48. return envelope.Response.Data.Results.InfoArray
  49. }
  50. return envelope.Response.Data.InfoArray
  51. }
  52. // stubNoDirUser answers DirInfo with no directory TLVs, which is what the service
  53. // returns for a name that belongs to no user. Every search test needs one, since
  54. // Search does an identity lookup alongside the directory query.
  55. func stubNoDirUser(t *testing.T) *mockLocateService {
  56. ls := newMockLocateService(t)
  57. ls.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  58. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{
  59. Status: wire.LocateGetDirReplyOK,
  60. }}, nil).Maybe()
  61. return ls
  62. }
  63. func TestMemberDirHandler_Search_Keyword(t *testing.T) {
  64. dirSvc := newMockDirSearchService(t)
  65. // keyword=haha must map to the ODir interest TLV.
  66. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
  67. v, ok := q.String(wire.ODirTLVInterest)
  68. return ok && v == "haha"
  69. })).Return(searchReply(wire.ODirSearchResponseOK, result("FoundUser", "Found", "User")), nil)
  70. h := &MemberDirHandler{DirSearchService: dirSvc, LocateService: stubNoDirUser(t), Logger: slog.Default()}
  71. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  72. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dhaha&nToGet=200", nil)
  73. rr := httptest.NewRecorder()
  74. h.Search(rr, req, session)
  75. assert.Equal(t, http.StatusOK, rr.Code)
  76. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  77. require.Len(t, infoArray, 1)
  78. assert.Equal(t, "founduser", infoArray[0].Profile.AimID)
  79. assert.Equal(t, "FoundUser", infoArray[0].Profile.DisplayID)
  80. assert.Equal(t, "Found", infoArray[0].Profile.FirstName)
  81. }
  82. func TestMemberDirHandler_Search_FirstLastName(t *testing.T) {
  83. dirSvc := newMockDirSearchService(t)
  84. // firstName/lastName must map to the ODir name TLVs, not interest.
  85. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
  86. first, hasFirst := q.String(wire.ODirTLVFirstName)
  87. last, hasLast := q.String(wire.ODirTLVLastName)
  88. _, hasInterest := q.String(wire.ODirTLVInterest)
  89. return hasFirst && first == "Bob" && hasLast && last == "Smith" && !hasInterest
  90. })).Return(searchReply(wire.ODirSearchResponseOK, result("Bob", "Bob", "Smith")), nil)
  91. h := &MemberDirHandler{DirSearchService: dirSvc, LocateService: stubNoDirUser(t), Logger: slog.Default()}
  92. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  93. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=firstName%3DBob%2ClastName%3DSmith", nil)
  94. rr := httptest.NewRecorder()
  95. h.Search(rr, req, session)
  96. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  97. require.Len(t, infoArray, 1)
  98. assert.Equal(t, "bob", infoArray[0].Profile.AimID)
  99. }
  100. func TestMemberDirHandler_Search_ExcludesSelf(t *testing.T) {
  101. dirSvc := newMockDirSearchService(t)
  102. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).Return(
  103. searchReply(wire.ODirSearchResponseOK,
  104. result("Me", "", ""), // caller — must be filtered out
  105. result("Other", "", ""), // kept
  106. ), nil)
  107. h := &MemberDirHandler{DirSearchService: dirSvc, LocateService: stubNoDirUser(t), Logger: slog.Default()}
  108. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("M E")}
  109. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx", nil)
  110. rr := httptest.NewRecorder()
  111. h.Search(rr, req, session)
  112. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  113. require.Len(t, infoArray, 1)
  114. assert.Equal(t, "other", infoArray[0].Profile.AimID)
  115. }
  116. func TestMemberDirHandler_Search_RespectsJSONPCallback(t *testing.T) {
  117. dirSvc := newMockDirSearchService(t)
  118. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).Return(
  119. searchReply(wire.ODirSearchResponseOK), nil)
  120. h := &MemberDirHandler{DirSearchService: dirSvc, LocateService: stubNoDirUser(t), Logger: slog.Default()}
  121. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  122. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx&c=_callbacks_._abc", nil)
  123. rr := httptest.NewRecorder()
  124. h.Search(rr, req, session)
  125. // The web client loads this via a <script> tag, so the response must be
  126. // JavaScript (JSONP), not application/json — otherwise the browser CORB-blocks it.
  127. // The charset is explicit because a script tag otherwise decodes using the host
  128. // page's encoding, which mangles non-ASCII screen names.
  129. assert.Equal(t, "application/javascript; charset=utf-8", rr.Header().Get("Content-Type"))
  130. assert.Contains(t, rr.Body.String(), "_callbacks_._abc(")
  131. }
  132. func TestMemberDirHandler_Get_Self(t *testing.T) {
  133. reply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  134. reply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Me"))
  135. reply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Myself"))
  136. locSvc := newMockLocateService(t)
  137. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
  138. return q.ScreenName == "me"
  139. })).Return(wire.SNACMessage{Body: reply}, nil)
  140. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  141. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  142. // No "t" param: defaults to self.
  143. req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid", nil)
  144. rr := httptest.NewRecorder()
  145. h.Get(rr, req, session)
  146. infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
  147. require.Len(t, infoArray, 1)
  148. assert.Equal(t, "me", infoArray[0].Profile.AimID)
  149. assert.Equal(t, "Me", infoArray[0].Profile.FirstName)
  150. assert.Equal(t, "Myself", infoArray[0].Profile.LastName)
  151. }
  152. func TestMemberDirHandler_Get_LabelsEachTargetWithOwnIdentity(t *testing.T) {
  153. dirReply := func(firstName string) wire.SNACMessage {
  154. reply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  155. reply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, firstName))
  156. return wire.SNACMessage{Body: reply}
  157. }
  158. locSvc := newMockLocateService(t)
  159. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
  160. return q.ScreenName == "Bob Smith"
  161. })).Return(dirReply("Bob"), nil)
  162. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
  163. return q.ScreenName == "alice"
  164. })).Return(dirReply("Alice"), nil)
  165. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  166. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("Bob Smith")}
  167. req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid&t=Bob+Smith,alice", nil)
  168. rr := httptest.NewRecorder()
  169. h.Get(rr, req, session)
  170. // Each result carries the identity of the target it describes, not the
  171. // caller's — the client keys users by aimId.
  172. infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
  173. require.Len(t, infoArray, 2)
  174. assert.Equal(t, "bobsmith", infoArray[0].Profile.AimID)
  175. assert.Equal(t, "Bob Smith", infoArray[0].Profile.DisplayID)
  176. assert.Equal(t, "Bob", infoArray[0].Profile.FirstName)
  177. assert.Equal(t, "alice", infoArray[1].Profile.AimID)
  178. assert.Equal(t, "alice", infoArray[1].Profile.DisplayID)
  179. assert.Equal(t, "Alice", infoArray[1].Profile.FirstName)
  180. }
  181. func TestMemberDirHandler_Get_CapsTargetFanOut(t *testing.T) {
  182. locSvc := newMockLocateService(t)
  183. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  184. Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil).
  185. Times(maxMemberDirTargets)
  186. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  187. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  188. // Every target costs a directory lookup, so an arbitrarily long "t" list
  189. // must not translate into an unbounded number of them.
  190. targets := make([]string, maxMemberDirTargets+50)
  191. for i := range targets {
  192. targets[i] = fmt.Sprintf("user%d", i)
  193. }
  194. req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid&t="+strings.Join(targets, ","), nil)
  195. rr := httptest.NewRecorder()
  196. h.Get(rr, req, session)
  197. infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
  198. assert.Len(t, infoArray, maxMemberDirTargets)
  199. }
  200. func TestMemberDirHandler_Update_PersistsNameAndPreservesOtherFields(t *testing.T) {
  201. // Current directory record has a city set that the name form must not wipe.
  202. current := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  203. current.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Old"))
  204. current.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Name"))
  205. current.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
  206. locSvc := newMockLocateService(t)
  207. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  208. Return(wire.SNACMessage{Body: current}, nil)
  209. // The set request must carry the new name AND the preserved city.
  210. locSvc.EXPECT().SetDirInfo(mock.Anything, mock.Anything, mock.Anything,
  211. mock.MatchedBy(func(b wire.SNAC_0x02_0x09_LocateSetDirInfo) bool {
  212. first, _ := b.String(wire.ODirTLVFirstName)
  213. last, _ := b.String(wire.ODirTLVLastName)
  214. city, _ := b.String(wire.ODirTLVCity)
  215. return first == "Mike" && last == "K" && city == "Reno"
  216. })).Return(wire.SNACMessage{}, nil)
  217. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  218. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
  219. req := httptest.NewRequest("GET",
  220. "/memberDir/update?aimsid=sid&set=firstName%3DMike&set=lastName%3DK&set=hideLevel%3DemailsAndCellular", nil)
  221. rr := httptest.NewRecorder()
  222. h.Update(rr, req, session)
  223. assert.Equal(t, http.StatusOK, rr.Code)
  224. }
  225. func TestMemberDirHandler_Update_AbortsWhenCurrentInfoUnreadable(t *testing.T) {
  226. locSvc := newMockLocateService(t)
  227. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  228. Return(wire.SNACMessage{}, io.ErrUnexpectedEOF)
  229. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  230. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
  231. req := httptest.NewRequest("GET", "/memberDir/update?aimsid=sid&set=firstName%3DMike&set=lastName%3DK", nil)
  232. rr := httptest.NewRecorder()
  233. h.Update(rr, req, session)
  234. // SetDirectoryInfo replaces every column, so writing a record we couldn't
  235. // seed would blank the fields this form doesn't edit. Report the failure
  236. // instead.
  237. locSvc.AssertNotCalled(t, "SetDirInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
  238. var envelope struct {
  239. Response struct {
  240. StatusCode int `json:"statusCode"`
  241. } `json:"response"`
  242. }
  243. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
  244. assert.Equal(t, http.StatusInternalServerError, envelope.Response.StatusCode)
  245. }
  246. func TestMemberDirHandler_Update_ReadsDoubleEncodedFormBody(t *testing.T) {
  247. // A form body encodes each "set" value twice — once building the pair, once
  248. // building the body — and arrives with no Content-Type, which is what
  249. // parseBodyForm's defaulting exists to handle.
  250. current := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  251. current.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
  252. locSvc := newMockLocateService(t)
  253. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  254. Return(wire.SNACMessage{Body: current}, nil)
  255. locSvc.EXPECT().SetDirInfo(mock.Anything, mock.Anything, mock.Anything,
  256. mock.MatchedBy(func(b wire.SNAC_0x02_0x09_LocateSetDirInfo) bool {
  257. first, _ := b.String(wire.ODirTLVFirstName)
  258. last, _ := b.String(wire.ODirTLVLastName)
  259. city, _ := b.String(wire.ODirTLVCity)
  260. // Without the second unescape these arrive as "Bob%20Smith" and
  261. // "O%27Brien" and are stored verbatim.
  262. return first == "Bob Smith" && last == "O'Brien" && city == "Reno"
  263. })).Return(wire.SNACMessage{}, nil)
  264. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  265. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
  266. form := url.Values{}
  267. form.Set("aimsid", "sid")
  268. form.Set("f", "json")
  269. form.Add("set", "firstName=Bob%20Smith")
  270. form.Add("set", "lastName=O%27Brien")
  271. form.Add("set", "gender=unknown")
  272. req := httptest.NewRequest("POST", "/memberDir/update", strings.NewReader(form.Encode()))
  273. rr := httptest.NewRecorder()
  274. h.Update(rr, req, session)
  275. assert.Equal(t, http.StatusOK, rr.Code)
  276. }
  277. func TestMemberDirHandler_Update_QueryValuesAreNotUnescapedTwice(t *testing.T) {
  278. // Query values are encoded once and are final after the query decoder runs.
  279. // Unescaping again would corrupt a name carrying a literal '%'.
  280. current := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  281. locSvc := newMockLocateService(t)
  282. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  283. Return(wire.SNACMessage{Body: current}, nil)
  284. locSvc.EXPECT().SetDirInfo(mock.Anything, mock.Anything, mock.Anything,
  285. mock.MatchedBy(func(b wire.SNAC_0x02_0x09_LocateSetDirInfo) bool {
  286. first, _ := b.String(wire.ODirTLVFirstName)
  287. last, _ := b.String(wire.ODirTLVLastName)
  288. return first == "100%" && last == "Smith"
  289. })).Return(wire.SNACMessage{}, nil)
  290. h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
  291. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
  292. req := httptest.NewRequest("GET",
  293. "/memberDir/update?aimsid=sid&set=firstName%3D100%25&set=lastName%3DSmith", nil)
  294. rr := httptest.NewRecorder()
  295. h.Update(rr, req, session)
  296. assert.Equal(t, http.StatusOK, rr.Code)
  297. }
  298. func TestServer_MemberDirUpdateIsRoutedForGETAndPOST(t *testing.T) {
  299. // Go 1.22 mux patterns are method-exact, so registering only GET sends a POST to
  300. // the catch-all 404. Neither request below carries an aimsid, so a routed one
  301. // is rejected by the session middleware (400) and an unrouted one 404s.
  302. srv := NewServer([]string{"127.0.0.1:0"}, slog.Default(), Handler{Logger: slog.Default()},
  303. NewSessionManager())
  304. require.NotEmpty(t, srv.servers)
  305. mux := srv.servers[0].Handler
  306. for _, method := range []string{"GET", "POST"} {
  307. t.Run(method, func(t *testing.T) {
  308. req := httptest.NewRequest(method, "/memberDir/update", strings.NewReader(""))
  309. rr := httptest.NewRecorder()
  310. mux.ServeHTTP(rr, req)
  311. assert.NotEqual(t, http.StatusNotFound, rr.Code,
  312. "%s /memberDir/update is not registered", method)
  313. })
  314. }
  315. }
  316. // dirUser answers DirInfo as an existing user. The service appends directory TLVs
  317. // only for a user that exists, so their presence marks the name as found.
  318. func dirUser(t *testing.T, firstName, lastName string) *mockLocateService {
  319. reply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
  320. reply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, firstName))
  321. reply.Append(wire.NewTLVBE(wire.ODirTLVLastName, lastName))
  322. ls := newMockLocateService(t)
  323. ls.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  324. Return(wire.SNACMessage{Body: reply}, nil).Maybe()
  325. return ls
  326. }
  327. func TestMemberDirHandler_Search_MatchesScreenName(t *testing.T) {
  328. // An add-contact box that takes an email or UIN sends the value as keyword, but
  329. // ODir searches interests, names and email — never the screen name.
  330. dirSvc := newMockDirSearchService(t)
  331. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).
  332. Return(searchReply(wire.ODirSearchResponseOK), nil)
  333. h := &MemberDirHandler{
  334. DirSearchService: dirSvc,
  335. LocateService: dirUser(t, "Bob", "Smith"),
  336. Logger: slog.Default(),
  337. }
  338. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  339. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3D100888", nil)
  340. rr := httptest.NewRecorder()
  341. h.Search(rr, req, session)
  342. assert.Equal(t, http.StatusOK, rr.Code)
  343. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  344. require.Len(t, infoArray, 1)
  345. assert.Equal(t, "100888", infoArray[0].Profile.AimID)
  346. assert.Equal(t, "Bob", infoArray[0].Profile.FirstName)
  347. }
  348. func TestMemberDirHandler_Search_ScreenNameMatchIsFoundForBlankProfile(t *testing.T) {
  349. // A freshly created account has no directory info at all, and must still be
  350. // findable by name.
  351. dirSvc := newMockDirSearchService(t)
  352. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).
  353. Return(searchReply(wire.ODirSearchResponseOK), nil)
  354. h := &MemberDirHandler{
  355. DirSearchService: dirSvc,
  356. LocateService: dirUser(t, "", ""),
  357. Logger: slog.Default(),
  358. }
  359. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  360. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3D100888", nil)
  361. rr := httptest.NewRecorder()
  362. h.Search(rr, req, session)
  363. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  364. require.Len(t, infoArray, 1)
  365. assert.Equal(t, "100888", infoArray[0].Profile.AimID)
  366. }
  367. func TestMemberDirHandler_Search_UnknownScreenNameYieldsNothing(t *testing.T) {
  368. // The identity lookup must not invent a profile for a name nobody holds.
  369. dirSvc := newMockDirSearchService(t)
  370. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).
  371. Return(searchReply(wire.ODirSearchResponseOK), nil)
  372. h := &MemberDirHandler{
  373. DirSearchService: dirSvc,
  374. LocateService: stubNoDirUser(t),
  375. Logger: slog.Default(),
  376. }
  377. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  378. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dnobody", nil)
  379. rr := httptest.NewRecorder()
  380. h.Search(rr, req, session)
  381. assert.Empty(t, decodeInfoArray(t, rr.Body.Bytes(), true))
  382. }
  383. func TestMemberDirHandler_Search_ScreenNameMatchDoesNotDisplaceInterestResults(t *testing.T) {
  384. // A keyword that also happens to name a user must return both the interest
  385. // matches and the identity hit, with any overlap appearing once.
  386. dirSvc := newMockDirSearchService(t)
  387. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
  388. v, ok := q.String(wire.ODirTLVInterest)
  389. return ok && v == "music"
  390. })).Return(searchReply(wire.ODirSearchResponseOK,
  391. result("music", "Music", "Fan"), // same user the name lookup finds
  392. result("OtherFan", "Other", "Fan"),
  393. ), nil)
  394. h := &MemberDirHandler{
  395. DirSearchService: dirSvc,
  396. LocateService: dirUser(t, "Music", "Fan"),
  397. Logger: slog.Default(),
  398. }
  399. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  400. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dmusic", nil)
  401. rr := httptest.NewRecorder()
  402. h.Search(rr, req, session)
  403. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  404. require.Len(t, infoArray, 2)
  405. assert.Equal(t, "music", infoArray[0].Profile.AimID)
  406. assert.Equal(t, "otherfan", infoArray[1].Profile.AimID)
  407. }
  408. func TestMemberDirHandler_Search_ExcludesSelfByScreenName(t *testing.T) {
  409. // Searching your own UIN must not offer you yourself as a contact.
  410. dirSvc := newMockDirSearchService(t)
  411. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).
  412. Return(searchReply(wire.ODirSearchResponseOK), nil)
  413. h := &MemberDirHandler{
  414. DirSearchService: dirSvc,
  415. LocateService: dirUser(t, "Me", "Myself"),
  416. Logger: slog.Default(),
  417. }
  418. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("100777")}
  419. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3D100777", nil)
  420. rr := httptest.NewRecorder()
  421. h.Search(rr, req, session)
  422. assert.Empty(t, decodeInfoArray(t, rr.Body.Bytes(), true))
  423. }
  424. func TestMemberDirHandler_Search_EmailKeywordUsesEmailSearch(t *testing.T) {
  425. // An address can only be an identifier, and ODir searches email directly, so it
  426. // must not be sent as an interest.
  427. dirSvc := newMockDirSearchService(t)
  428. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
  429. v, ok := q.String(wire.ODirTLVEmailAddress)
  430. _, isInterest := q.String(wire.ODirTLVInterest)
  431. return ok && v == "bob@example.com" && !isInterest
  432. })).Return(searchReply(wire.ODirSearchResponseOK, result("BobS", "Bob", "Smith")), nil)
  433. h := &MemberDirHandler{
  434. DirSearchService: dirSvc,
  435. LocateService: stubNoDirUser(t),
  436. Logger: slog.Default(),
  437. }
  438. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  439. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dbob%40example.com", nil)
  440. rr := httptest.NewRecorder()
  441. h.Search(rr, req, session)
  442. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  443. require.Len(t, infoArray, 1)
  444. assert.Equal(t, "bobs", infoArray[0].Profile.AimID)
  445. }
  446. // Mandarin escapes each match value before its request builder escapes the whole
  447. // parameter (IcqSearchOptionsBuilder.appendOption + HttpParamsBuilder.build), so
  448. // the query parser leaves one layer on. The web client escapes nothing. Both have
  449. // to arrive as the text the user typed.
  450. func TestParseMatch(t *testing.T) {
  451. tests := []struct {
  452. name string
  453. match string
  454. want map[string]string
  455. }{
  456. {
  457. // What Mandarin sends: the query parser has already removed the outer
  458. // layer, leaving the values escaped.
  459. name: "doubly escaped values are decoded",
  460. match: "keyword=bob%40example.com,age=19-26,gender=female",
  461. want: map[string]string{"keyword": "bob@example.com", "age": "19-26", "gender": "female"},
  462. },
  463. {
  464. // StringUtil.urlEncode writes a space as %20, never '+'.
  465. name: "escaped spaces survive",
  466. match: "firstName=John,lastName=van%20Smith",
  467. want: map[string]string{"firstName": "John", "lastName": "van Smith"},
  468. },
  469. {
  470. // An escaped separator must not split the pair, and must come back.
  471. name: "escaped separators are not delimiters",
  472. match: "keyword=rock%2C%20paper",
  473. want: map[string]string{"keyword": "rock, paper"},
  474. },
  475. {
  476. // What the web client sends: nothing is escaped, and one pass over an
  477. // unescaped value changes nothing.
  478. name: "unescaped values are untouched",
  479. match: "firstName=John,lastName=Smith",
  480. want: map[string]string{"firstName": "John", "lastName": "Smith"},
  481. },
  482. {
  483. // A '+' is a literal here, not a space. QueryUnescape would eat it.
  484. name: "a literal plus is preserved",
  485. match: "keyword=C++",
  486. want: map[string]string{"keyword": "C++"},
  487. },
  488. {
  489. // Not valid escaping, so it is a literal '%' and stays one.
  490. name: "an unescapable value is kept verbatim",
  491. match: "keyword=100% cotton",
  492. want: map[string]string{"keyword": "100% cotton"},
  493. },
  494. {
  495. name: "pairs without a separator are skipped",
  496. match: "keyword=hi,garbage,=novalue",
  497. want: map[string]string{"keyword": "hi"},
  498. },
  499. }
  500. for _, tt := range tests {
  501. t.Run(tt.name, func(t *testing.T) {
  502. assert.Equal(t, tt.want, parseMatch(tt.match))
  503. })
  504. }
  505. }
  506. // An email typed into Mandarin's search box arrives escaped twice. Unless the
  507. // second layer comes off, the keyword holds no literal '@', the email branch of
  508. // buildDirInfoQuery never fires, and the address is searched as an interest.
  509. func TestMemberDirHandler_Search_DoublyEscapedEmailUsesEmailSearch(t *testing.T) {
  510. dirSvc := newMockDirSearchService(t)
  511. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
  512. v, ok := q.String(wire.ODirTLVEmailAddress)
  513. _, isInterest := q.String(wire.ODirTLVInterest)
  514. return ok && v == "bob@example.com" && !isInterest
  515. })).Return(searchReply(wire.ODirSearchResponseOK, result("BobS", "Bob", "Smith")), nil)
  516. h := &MemberDirHandler{
  517. DirSearchService: dirSvc,
  518. LocateService: stubNoDirUser(t),
  519. Logger: slog.Default(),
  520. }
  521. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  522. // "keyword=bob%40example.com" with the whole parameter escaped once more.
  523. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dbob%2540example.com", nil)
  524. rr := httptest.NewRecorder()
  525. h.Search(rr, req, session)
  526. infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
  527. require.Len(t, infoArray, 1)
  528. assert.Equal(t, "bobs", infoArray[0].Profile.AimID)
  529. }
  530. func TestMemberDirHandler_Search_ReportsDirectoryFailure(t *testing.T) {
  531. dirSvc := newMockDirSearchService(t)
  532. dirSvc.EXPECT().InfoQuery(mock.Anything, mock.Anything, mock.Anything).
  533. Return(wire.SNACMessage{}, io.ErrUnexpectedEOF)
  534. h := &MemberDirHandler{
  535. DirSearchService: dirSvc,
  536. // The screen name resolves, so a degraded search would have a profile to
  537. // answer with. A failed directory query is still a failed request.
  538. LocateService: dirUser(t, "Bob", "Smith"),
  539. Logger: slog.Default(),
  540. }
  541. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  542. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dbobs", nil)
  543. rr := httptest.NewRecorder()
  544. h.Search(rr, req, session)
  545. var envelope struct {
  546. Response struct {
  547. StatusCode int `json:"statusCode"`
  548. } `json:"response"`
  549. }
  550. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
  551. assert.Equal(t, http.StatusInternalServerError, envelope.Response.StatusCode)
  552. assert.NotContains(t, rr.Body.String(), "infoArray")
  553. }
  554. func TestMemberDirHandler_Search_ReportsScreenNameLookupFailure(t *testing.T) {
  555. locSvc := newMockLocateService(t)
  556. locSvc.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
  557. Return(wire.SNACMessage{}, io.ErrUnexpectedEOF)
  558. h := &MemberDirHandler{
  559. // No expectation: the lookup runs first, so the directory query is never
  560. // reached and a search that appears to succeed can never be answered.
  561. DirSearchService: newMockDirSearchService(t),
  562. LocateService: locSvc,
  563. Logger: slog.Default(),
  564. }
  565. session := &Session{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
  566. req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dbobs", nil)
  567. rr := httptest.NewRecorder()
  568. h.Search(rr, req, session)
  569. var envelope struct {
  570. Response struct {
  571. StatusCode int `json:"statusCode"`
  572. } `json:"response"`
  573. }
  574. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
  575. assert.Equal(t, http.StatusInternalServerError, envelope.Response.StatusCode)
  576. assert.NotContains(t, rr.Body.String(), "infoArray")
  577. }