expressions_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. package handlers
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "log/slog"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "github.com/stretchr/testify/assert"
  10. "github.com/stretchr/testify/mock"
  11. "github.com/mk6i/open-oscar-server/wire"
  12. )
  13. // blankIconGIF stands in for the blank placeholder the real BART service returns
  14. // for the clear-icon hash; distinct from iconGIF so tests can tell them apart.
  15. var blankIconGIF = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xff}
  16. // newExpressionsHandler builds a handler whose target user has the given icon,
  17. // or no icon when id is nil. Its BART mock mirrors the real service: the
  18. // clear-icon hash resolves to the blank placeholder, any other hash to iconGIF.
  19. func newExpressionsHandler(id *wire.BARTID) *ExpressionsHandler {
  20. iconRetriever := &MockBuddyIconRetriever{}
  21. iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).Return(id, nil).Maybe()
  22. bartService := &MockBARTService{}
  23. bartService.On("RetrieveItem", mock.Anything, mock.Anything,
  24. mock.MatchedBy(func(q wire.SNAC_0x10_0x04_BARTDownloadQuery) bool { return q.HasClearIconHash() })).
  25. Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: blankIconGIF}}, nil).Maybe()
  26. bartService.On("RetrieveItem", mock.Anything, mock.Anything, mock.Anything).
  27. Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: iconGIF}}, nil).Maybe()
  28. return NewExpressionsHandler(BuddyIconSource{
  29. IconRetriever: iconRetriever,
  30. BARTService: bartService,
  31. Logger: slog.Default(),
  32. }, slog.Default())
  33. }
  34. func TestExpressionsHandler_Get_ServesIconBytes(t *testing.T) {
  35. h := newExpressionsHandler(bartID([]byte{0xde, 0xad}))
  36. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead", nil)
  37. w := httptest.NewRecorder()
  38. h.Get(w, r)
  39. assert.Equal(t, http.StatusOK, w.Code)
  40. assert.Equal(t, iconGIF, w.Body.Bytes())
  41. assert.Equal(t, "image/gif", w.Header().Get("Content-Type"))
  42. // The URL pins the icon hash, so it always resolves to the same image.
  43. assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
  44. }
  45. func TestExpressionsHandler_Get_IconWithoutHashIsNotCached(t *testing.T) {
  46. // Without a hash the URL keeps resolving to whatever the current icon is, so
  47. // caching it would pin a stale image.
  48. h := newExpressionsHandler(bartID([]byte{0xde, 0xad}))
  49. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon", nil)
  50. w := httptest.NewRecorder()
  51. h.Get(w, r)
  52. assert.Equal(t, http.StatusOK, w.Code)
  53. assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
  54. }
  55. func TestExpressionsHandler_Get_MissingIconServesPlaceholder(t *testing.T) {
  56. // A user with no icon still serves the blank placeholder for the hash-less
  57. // URL, so the client's <img> renders something and a cleared icon stops
  58. // showing the previous one rather than 404ing.
  59. h := newExpressionsHandler(nil)
  60. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon", nil)
  61. w := httptest.NewRecorder()
  62. h.Get(w, r)
  63. assert.Equal(t, http.StatusOK, w.Code)
  64. assert.Equal(t, blankIconGIF, w.Body.Bytes())
  65. assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
  66. }
  67. func TestExpressionsHandler_Get_BartIdServesRequestedHash(t *testing.T) {
  68. // Even though the user's *current* icon is hash B, a URL pinned to hash A must
  69. // serve A's bytes and cache immutably — otherwise a cached URL could later
  70. // resolve to a different image.
  71. iconRetriever := &MockBuddyIconRetriever{}
  72. iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).
  73. Return(bartID([]byte{0xbb}), nil).Maybe()
  74. bytesA := []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xa1}
  75. bartService := &MockBARTService{}
  76. bartService.On("RetrieveItem", mock.Anything, mock.Anything,
  77. mock.MatchedBy(func(q wire.SNAC_0x10_0x04_BARTDownloadQuery) bool {
  78. return bytes.Equal(q.Hash, []byte{0xaa})
  79. })).
  80. Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{Data: bytesA}}, nil).Once()
  81. h := NewExpressionsHandler(BuddyIconSource{
  82. IconRetriever: iconRetriever,
  83. BARTService: bartService,
  84. Logger: slog.Default(),
  85. }, slog.Default())
  86. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=aa", nil)
  87. w := httptest.NewRecorder()
  88. h.Get(w, r)
  89. assert.Equal(t, http.StatusOK, w.Code)
  90. assert.Equal(t, bytesA, w.Body.Bytes())
  91. assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
  92. bartService.AssertExpectations(t)
  93. }
  94. func TestExpressionsHandler_Get_UnknownBartIdNotFound(t *testing.T) {
  95. iconRetriever := &MockBuddyIconRetriever{}
  96. iconRetriever.On("BuddyIconMetadata", mock.Anything, mock.Anything).
  97. Return(bartID([]byte{0xbb}), nil).Maybe()
  98. // An empty reply means the hash is not stored.
  99. bartService := &MockBARTService{}
  100. bartService.On("RetrieveItem", mock.Anything, mock.Anything, mock.Anything).
  101. Return(wire.SNACMessage{Body: wire.SNAC_0x10_0x05_BARTDownloadReply{}}, nil).Once()
  102. h := NewExpressionsHandler(BuddyIconSource{
  103. IconRetriever: iconRetriever,
  104. BARTService: bartService,
  105. Logger: slog.Default(),
  106. }, slog.Default())
  107. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&type=buddyIcon&bartId=abcdef", nil)
  108. w := httptest.NewRecorder()
  109. h.Get(w, r)
  110. assert.Equal(t, http.StatusNotFound, w.Code)
  111. }
  112. func TestExpressionsHandler_Get_ListsBigBuddyIcon(t *testing.T) {
  113. // This is the shape the client scans for its large icon rendering.
  114. h := newExpressionsHandler(bartID([]byte{0xde, 0xad}))
  115. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly", nil)
  116. r.Host = "api.example.com"
  117. w := httptest.NewRecorder()
  118. h.Get(w, r)
  119. assert.Equal(t, http.StatusOK, w.Code)
  120. var got struct {
  121. Response struct {
  122. StatusCode int `json:"statusCode"`
  123. Data struct {
  124. Expressions []struct {
  125. Type string `json:"type"`
  126. URL string `json:"url"`
  127. } `json:"expressions"`
  128. } `json:"data"`
  129. } `json:"response"`
  130. }
  131. assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
  132. assert.Equal(t, 200, got.Response.StatusCode)
  133. assert.Len(t, got.Response.Data.Expressions, 1)
  134. assert.Equal(t, "bigBuddyIcon", got.Response.Data.Expressions[0].Type)
  135. assert.Equal(t,
  136. "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
  137. got.Response.Data.Expressions[0].URL)
  138. }
  139. func TestExpressionsHandler_Get_ListsNothingWithoutIcon(t *testing.T) {
  140. h := newExpressionsHandler(nil)
  141. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly", nil)
  142. w := httptest.NewRecorder()
  143. h.Get(w, r)
  144. assert.Equal(t, http.StatusOK, w.Code)
  145. assert.Contains(t, w.Body.String(), `"expressions":[]`)
  146. }
  147. func TestExpressionsHandler_Get_Redirect(t *testing.T) {
  148. h := newExpressionsHandler(bartID([]byte{0xde, 0xad}))
  149. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&f=redirect", nil)
  150. r.Host = "api.example.com"
  151. w := httptest.NewRecorder()
  152. h.Get(w, r)
  153. assert.Equal(t, http.StatusFound, w.Code)
  154. assert.Equal(t,
  155. "http://api.example.com/expressions/get?t=mikekelly&type=buddyIcon&bartId=dead",
  156. w.Header().Get("Location"))
  157. }
  158. func TestExpressionsHandler_Get_RedirectWithoutIcon(t *testing.T) {
  159. h := newExpressionsHandler(nil)
  160. r := httptest.NewRequest(http.MethodGet, "/expressions/get?t=mikekelly&f=redirect", nil)
  161. w := httptest.NewRecorder()
  162. h.Get(w, r)
  163. assert.Equal(t, http.StatusNotFound, w.Code)
  164. }
  165. func TestExpressionsHandler_Get_MissingTarget(t *testing.T) {
  166. h := newExpressionsHandler(nil)
  167. r := httptest.NewRequest(http.MethodGet, "/expressions/get", nil)
  168. w := httptest.NewRecorder()
  169. h.Get(w, r)
  170. assert.Equal(t, http.StatusBadRequest, w.Code)
  171. }