package server import ( "database/sql" "encoding/json" "image/color" "net/http" "net/http/httptest" "net/url" "path/filepath" "testing" "github.com/status-im/status-go/images" "github.com/status-im/status-go/eth-node/crypto" "github.com/status-im/status-go/common/dbsetup" "github.com/status-im/status-go/multiaccounts" mc "github.com/status-im/status-go/multiaccounts/common" "github.com/golang/protobuf/proto" "github.com/stretchr/testify/suite" "go.uber.org/zap" "github.com/status-im/status-go/appdatabase" "github.com/status-im/status-go/protocol/common" "github.com/status-im/status-go/protocol/protobuf" "github.com/status-im/status-go/protocol/sqlite" "github.com/status-im/status-go/protocol/tt" "github.com/status-im/status-go/t/helpers" ) func TestHandlersSuite(t *testing.T) { suite.Run(t, new(HandlersSuite)) } type HandlersSuite struct { suite.Suite db *sql.DB logger *zap.Logger } func (s *HandlersSuite) SetupTest() { db, err := helpers.SetupTestMemorySQLDB(appdatabase.DbInitializer{}) s.Require().NoError(err) err = sqlite.Migrate(db) s.Require().NoError(err) s.logger = tt.MustCreateTestLogger() s.db = db } func (s *HandlersSuite) saveUserMessage(msg *common.Message) { whisperTimestamp := 0 source := "" text := "" contentType := 0 timestamp := 0 chatID := "1" localChatID := "1" responseTo := "" clockValue := 0 stmt, err := s.db.Prepare(` INSERT INTO user_messages ( id, whisper_timestamp, source, text, content_type, timestamp, chat_id, local_chat_id, response_to, clock_value, unfurled_links, unfurled_status_links ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) `) s.Require().NoError(err) links := []byte{} statusLinks := []byte{} if msg.UnfurledLinks != nil { links, err = json.Marshal(msg.UnfurledLinks) s.Require().NoError(err) } if msg.UnfurledStatusLinks != nil { statusLinks, err = proto.Marshal(msg.UnfurledStatusLinks) s.Require().NoError(err) } _, err = stmt.Exec( msg.ID, whisperTimestamp, source, text, contentType, timestamp, chatID, localChatID, responseTo, clockValue, links, statusLinks, ) s.Require().NoError(err) } func (s *HandlersSuite) httpGetReqRecorder(handler http.HandlerFunc, reqURL string) *httptest.ResponseRecorder { req, err := http.NewRequest("GET", reqURL, nil) s.Require().NoError(err) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) return rr } func (s *HandlersSuite) verifyHTTPResponseThumbnail(rr *httptest.ResponseRecorder, expectedPayload []byte) { s.Require().Equal(expectedPayload, rr.Body.Bytes()) s.Require().Equal("image/jpeg", rr.HeaderMap.Get("Content-Type")) s.Require().Equal("no-store", rr.HeaderMap.Get("Cache-Control")) } func (s *HandlersSuite) TestHandleLinkPreviewThumbnail() { previewURL := "https://github.com" defaultPayload := []byte{0xff, 0xd8, 0xff, 0xdb, 0x0, 0x84, 0x0, 0x50, 0x37, 0x3c, 0x46, 0x3c, 0x32, 0x50} msg := common.Message{ ID: "1", ChatMessage: &protobuf.ChatMessage{ UnfurledLinks: []*protobuf.UnfurledLink{ { Type: protobuf.UnfurledLink_LINK, Url: previewURL, ThumbnailWidth: 100, ThumbnailHeight: 200, }, }, }, } s.saveUserMessage(&msg) testCases := []struct { Name string ExpectedHTTPStatusCode int ThumbnailPayload []byte Parameters url.Values CheckFunc func(s *HandlersSuite, rr *httptest.ResponseRecorder) }{ { Name: "Test happy path", ExpectedHTTPStatusCode: http.StatusOK, ThumbnailPayload: defaultPayload, Parameters: url.Values{ "message-id": {msg.ID}, "url": {previewURL}, }, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.verifyHTTPResponseThumbnail(rr, msg.UnfurledLinks[0].ThumbnailPayload) }, }, { Name: "Test request with missing 'url' parameter", ThumbnailPayload: defaultPayload, ExpectedHTTPStatusCode: http.StatusBadRequest, Parameters: url.Values{ "message-id": {msg.ID}, }, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("missing query parameter 'url'\n", rr.Body.String()) }, }, { Name: "Test request with missing 'message-id' parameter", ThumbnailPayload: defaultPayload, ExpectedHTTPStatusCode: http.StatusBadRequest, Parameters: url.Values{ "url": {previewURL}, }, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("missing query parameter 'message-id'\n", rr.Body.String()) }, }, { Name: "Test mime type not supported", ThumbnailPayload: []byte("unsupported image"), ExpectedHTTPStatusCode: http.StatusNotImplemented, Parameters: url.Values{ "message-id": {msg.ID}, "url": {previewURL}, }, }, } handler := handleLinkPreviewThumbnail(s.db, s.logger) for _, tc := range testCases { s.Run(tc.Name, func() { msg.UnfurledLinks[0].ThumbnailPayload = tc.ThumbnailPayload s.saveUserMessage(&msg) requestURL := "/dummy?" + tc.Parameters.Encode() rr := s.httpGetReqRecorder(handler, requestURL) s.Require().Equal(tc.ExpectedHTTPStatusCode, rr.Code) if tc.CheckFunc != nil { tc.CheckFunc(s, rr) } }) } } func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() { contact := &protobuf.UnfurledStatusContactLink{ PublicKey: []byte("PublicKey_1"), Icon: &protobuf.UnfurledLinkThumbnail{ Width: 10, Height: 20, Payload: []byte{0xff, 0xd8, 0xff, 0xdb, 0x0, 0x84, 0x0, 0x50, 0x37, 0x3c, 0x46, 0x3c, 0x32, 0x50}, }, } contactWithUnsupportedImage := &protobuf.UnfurledStatusContactLink{ PublicKey: []byte("PublicKey_2"), Icon: &protobuf.UnfurledLinkThumbnail{ Width: 10, Height: 20, Payload: []byte("unsupported image"), }, } community := &protobuf.UnfurledStatusCommunityLink{ CommunityId: []byte("CommunityId_1"), Icon: &protobuf.UnfurledLinkThumbnail{ Width: 30, Height: 40, Payload: []byte{0xff, 0xd8, 0xff, 0xdb, 0x0, 0x84, 0x0, 0x50, 0x37, 0x3c, 0x46, 0x3c, 0x32, 0x51}, }, Banner: &protobuf.UnfurledLinkThumbnail{ Width: 50, Height: 60, Payload: []byte{0xff, 0xd8, 0xff, 0xdb, 0x0, 0x84, 0x0, 0x50, 0x37, 0x3c, 0x46, 0x3c, 0x32, 0x52}, }, } channel := &protobuf.UnfurledStatusChannelLink{ ChannelUuid: "ChannelUuid_1", Community: &protobuf.UnfurledStatusCommunityLink{ CommunityId: []byte("CommunityId_2"), Icon: &protobuf.UnfurledLinkThumbnail{ Width: 70, Height: 80, Payload: []byte{0xff, 0xd8, 0xff, 0xdb, 0x0, 0x84, 0x0, 0x50, 0x37, 0x3c, 0x46, 0x3c, 0x32, 0x53}, }, Banner: &protobuf.UnfurledLinkThumbnail{ Width: 90, Height: 100, Payload: []byte{0xff, 0xd8, 0xff, 0xdb, 0x0, 0x84, 0x0, 0x50, 0x37, 0x3c, 0x46, 0x3c, 0x32, 0x54}, }, }, } transaction := &protobuf.UnfurledStatusTransactionLink{ TxType: 2, Asset: "Asset_1", Amount: "Amount_1", Address: "Address_1", ToAsset: "ToAsset_1", ChainId: 11, } unfurledContact := &protobuf.UnfurledStatusLink{ Url: "https://status.app/u/", Payload: &protobuf.UnfurledStatusLink_Contact{ Contact: contact, }, } unfurledContactWithUnsupportedImage := &protobuf.UnfurledStatusLink{ Url: "https://status.app/u/", Payload: &protobuf.UnfurledStatusLink_Contact{ Contact: contactWithUnsupportedImage, }, } unfurledCommunity := &protobuf.UnfurledStatusLink{ Url: "https://status.app/c/", Payload: &protobuf.UnfurledStatusLink_Community{ Community: community, }, } unfurledChannel := &protobuf.UnfurledStatusLink{ Url: "https://status.app/cc/", Payload: &protobuf.UnfurledStatusLink_Channel{ Channel: channel, }, } unfurledTransaction := &protobuf.UnfurledStatusLink{ Url: "https://status.app/tx/", Payload: &protobuf.UnfurledStatusLink_Transaction{ Transaction: transaction, }, } const ( messageIDContactOnly = "1" messageIDCommunityOnly = "2" messageIDChannelOnly = "3" messageIDAllLinks = "4" messageIDUnsupportedImage = "5" messageIDTransactionOnly = "6" ) s.saveUserMessage(&common.Message{ ID: messageIDContactOnly, ChatMessage: &protobuf.ChatMessage{ UnfurledStatusLinks: &protobuf.UnfurledStatusLinks{ UnfurledStatusLinks: []*protobuf.UnfurledStatusLink{ unfurledContact, }, }, }, }) s.saveUserMessage(&common.Message{ ID: messageIDCommunityOnly, ChatMessage: &protobuf.ChatMessage{ UnfurledStatusLinks: &protobuf.UnfurledStatusLinks{ UnfurledStatusLinks: []*protobuf.UnfurledStatusLink{ unfurledCommunity, }, }, }, }) s.saveUserMessage(&common.Message{ ID: messageIDChannelOnly, ChatMessage: &protobuf.ChatMessage{ UnfurledStatusLinks: &protobuf.UnfurledStatusLinks{ UnfurledStatusLinks: []*protobuf.UnfurledStatusLink{ unfurledChannel, }, }, }, }) s.saveUserMessage(&common.Message{ ID: messageIDAllLinks, ChatMessage: &protobuf.ChatMessage{ UnfurledStatusLinks: &protobuf.UnfurledStatusLinks{ UnfurledStatusLinks: []*protobuf.UnfurledStatusLink{ unfurledContact, unfurledCommunity, unfurledChannel, unfurledTransaction, }, }, }, }) s.saveUserMessage(&common.Message{ ID: messageIDUnsupportedImage, ChatMessage: &protobuf.ChatMessage{ UnfurledStatusLinks: &protobuf.UnfurledStatusLinks{ UnfurledStatusLinks: []*protobuf.UnfurledStatusLink{ unfurledContactWithUnsupportedImage, }, }, }, }) s.saveUserMessage(&common.Message{ ID: messageIDTransactionOnly, ChatMessage: &protobuf.ChatMessage{ UnfurledStatusLinks: &protobuf.UnfurledStatusLinks{ UnfurledStatusLinks: []*protobuf.UnfurledStatusLink{ unfurledTransaction, }, }, }, }) testCases := []struct { Name string ExpectedHTTPStatusCode int Parameters url.Values CheckFunc func(s *HandlersSuite, rr *httptest.ResponseRecorder) }{ { Name: "Test valid contact icon link", Parameters: url.Values{ "message-id": {messageIDContactOnly}, "url": {unfurledContact.Url}, "image-id": {string(common.MediaServerContactIcon)}, }, ExpectedHTTPStatusCode: http.StatusOK, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.verifyHTTPResponseThumbnail(rr, unfurledContact.GetContact().Icon.Payload) }, }, { Name: "Test invalid request for community icon in a contact link", Parameters: url.Values{ "message-id": {messageIDContactOnly}, "url": {unfurledContact.Url}, "image-id": {string(common.MediaServerCommunityIcon)}, }, ExpectedHTTPStatusCode: http.StatusBadRequest, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("invalid query parameter 'image-id' value: this is not a community link\n", rr.Body.String()) }, }, { Name: "Test invalid request for cahnnel community banner in a contact link", Parameters: url.Values{ "message-id": {messageIDContactOnly}, "url": {unfurledContact.Url}, "image-id": {string(common.MediaServerChannelCommunityBanner)}, }, ExpectedHTTPStatusCode: http.StatusBadRequest, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("invalid query parameter 'image-id' value: this is not a community channel link\n", rr.Body.String()) }, }, { Name: "Test invalid request for channel community banner in a contact link", Parameters: url.Values{ "message-id": {messageIDContactOnly}, "url": {unfurledContact.Url}, "image-id": {"contact-banner"}, }, ExpectedHTTPStatusCode: http.StatusBadRequest, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("invalid query parameter 'image-id' value: value not supported\n", rr.Body.String()) }, }, { Name: "Test valid community icon link", Parameters: url.Values{ "message-id": {messageIDCommunityOnly}, "url": {unfurledCommunity.Url}, "image-id": {string(common.MediaServerCommunityIcon)}, }, ExpectedHTTPStatusCode: http.StatusOK, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.verifyHTTPResponseThumbnail(rr, unfurledCommunity.GetCommunity().Icon.Payload) }, }, { Name: "Test valid community banner link", Parameters: url.Values{ "message-id": {messageIDCommunityOnly}, "url": {unfurledCommunity.Url}, "image-id": {string(common.MediaServerCommunityBanner)}, }, ExpectedHTTPStatusCode: http.StatusOK, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.verifyHTTPResponseThumbnail(rr, unfurledCommunity.GetCommunity().Banner.Payload) }, }, { Name: "Test valid channel community icon link", Parameters: url.Values{ "message-id": {messageIDChannelOnly}, "url": {unfurledChannel.Url}, "image-id": {string(common.MediaServerChannelCommunityIcon)}, }, ExpectedHTTPStatusCode: http.StatusOK, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.verifyHTTPResponseThumbnail(rr, unfurledChannel.GetChannel().GetCommunity().Icon.Payload) }, }, { Name: "Test valid channel community banner link", Parameters: url.Values{ "message-id": {messageIDChannelOnly}, "url": {unfurledChannel.Url}, "image-id": {string(common.MediaServerChannelCommunityBanner)}, }, ExpectedHTTPStatusCode: http.StatusOK, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.verifyHTTPResponseThumbnail(rr, unfurledChannel.GetChannel().GetCommunity().Banner.Payload) }, }, { Name: "Test valid contact icon link in a diverse message", Parameters: url.Values{ "message-id": {messageIDAllLinks}, "url": {unfurledContact.Url}, "image-id": {string(common.MediaServerContactIcon)}, }, ExpectedHTTPStatusCode: http.StatusOK, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.verifyHTTPResponseThumbnail(rr, unfurledContact.GetContact().Icon.Payload) }, }, { Name: "Test valid community icon link in a diverse message", Parameters: url.Values{ "message-id": {messageIDAllLinks}, "url": {unfurledCommunity.Url}, "image-id": {string(common.MediaServerCommunityIcon)}, }, ExpectedHTTPStatusCode: http.StatusOK, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.verifyHTTPResponseThumbnail(rr, unfurledCommunity.GetCommunity().Icon.Payload) }, }, { Name: "Test valid channel community icon link in a diverse message", Parameters: url.Values{ "message-id": {messageIDAllLinks}, "url": {unfurledChannel.Url}, "image-id": {string(common.MediaServerChannelCommunityIcon)}, }, ExpectedHTTPStatusCode: http.StatusOK, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.verifyHTTPResponseThumbnail(rr, unfurledChannel.GetChannel().GetCommunity().Icon.Payload) }, }, { Name: "Test mime type not supported", Parameters: url.Values{ "message-id": {messageIDUnsupportedImage}, "url": {unfurledContactWithUnsupportedImage.Url}, "image-id": {string(common.MediaServerContactIcon)}, }, ExpectedHTTPStatusCode: http.StatusNotImplemented, }, { Name: "Test request with missing 'message-id' parameter", Parameters: url.Values{ "url": {unfurledCommunity.Url}, "image-id": {string(common.MediaServerCommunityIcon)}, }, ExpectedHTTPStatusCode: http.StatusBadRequest, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("missing query parameter 'message-id'\n", rr.Body.String()) }, }, { Name: "Test request with missing 'url' parameter", Parameters: url.Values{ "message-id": {messageIDCommunityOnly}, "image-id": {string(common.MediaServerCommunityIcon)}, }, ExpectedHTTPStatusCode: http.StatusBadRequest, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("missing query parameter 'url'\n", rr.Body.String()) }, }, { Name: "Test request with missing 'image-id' parameter", Parameters: url.Values{ "message-id": {messageIDCommunityOnly}, "url": {unfurledCommunity.Url}, }, ExpectedHTTPStatusCode: http.StatusBadRequest, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("missing query parameter 'image-id'\n", rr.Body.String()) }, }, { Name: "Test request with missing 'message-id' parameter", Parameters: url.Values{ "url": {unfurledTransaction.Url}, }, ExpectedHTTPStatusCode: http.StatusBadRequest, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("missing query parameter 'message-id'\n", rr.Body.String()) }, }, { Name: "Test request with missing 'url' parameter", Parameters: url.Values{ "message-id": {messageIDTransactionOnly}, }, ExpectedHTTPStatusCode: http.StatusBadRequest, CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) { s.Require().Equal("missing query parameter 'url'\n", rr.Body.String()) }, }, } handler := handleStatusLinkPreviewThumbnail(s.db, s.logger) for _, tc := range testCases { s.Run(tc.Name, func() { requestURL := "/dummy?" + tc.Parameters.Encode() rr := s.httpGetReqRecorder(handler, requestURL) s.Require().Equal(tc.ExpectedHTTPStatusCode, rr.Code) if tc.CheckFunc != nil { tc.CheckFunc(s, rr) } }) } } func (s *HandlersSuite) validateResponse(w *httptest.ResponseRecorder) { s.Require().Equal(http.StatusOK, w.Code) s.Require().Equal("image/png", w.Header().Get("Content-Type")) n, err := w.Result().Body.Read(make([]byte, 100)) s.Require().NoError(err) s.Require().Greater(n, 0) } // TestHandleAccountInitialsImpl tests the handleAccountInitialsImpl function func (s *HandlersSuite) TestHandleAccountInitialsImpl() { // given an account without public key, and request to generate ring with keyUID of the account, // it should still response with a valid image without ring rather than response with empty image dbFile := filepath.Join(s.T().TempDir(), "accounts-tests-") db, err := multiaccounts.InitializeDB(dbFile) s.Require().NoError(err) defer db.Close() keyUID := "0x1" name := "Lopsided Goodnatured Bedbug" expected := multiaccounts.Account{Name: name, KeyUID: keyUID, CustomizationColor: mc.CustomizationColorBlue, ColorHash: nil, ColorID: 10, KDFIterations: dbsetup.ReducedKDFIterationsNumber, Timestamp: 1712856359} s.Require().NoError(db.SaveAccount(expected)) accounts, err := db.GetAccounts() s.Require().NoError(err) s.Require().Len(accounts, 1) s.Require().Equal(expected, accounts[0]) w := httptest.NewRecorder() f, err := filepath.Abs("../_assets/tests/UbuntuMono-Regular.ttf") s.Require().NoError(err) p := ImageParams{ Ring: true, RingWidth: 1, KeyUID: keyUID, InitialsLength: 2, BgColor: color.Transparent, Color: color.Transparent, FontFile: f, BgSize: 1, FontSize: 1, UppercaseRatio: 1.0, } handleAccountInitialsImpl(db, s.logger, w, p) s.validateResponse(w) // pass a public key to generate ring k, err := crypto.GenerateKey() s.Require().NoError(err) p.PublicKey = common.PubkeyToHex(&k.PublicKey) w = httptest.NewRecorder() handleAccountInitialsImpl(db, s.logger, w, p) s.Require().Equal(http.StatusOK, w.Code) } // TestHandleAccountImagesImpl tests the handleAccountImagesImpl function func (s *HandlersSuite) TestHandleAccountImagesImpl() { // given an account with identity images and without public key, and request to generate ring with keyUID of the account, // it should still response with a valid image without ring rather than response with empty image dbFile := filepath.Join(s.T().TempDir(), "accounts-tests-") db, err := multiaccounts.InitializeDB(dbFile) s.Require().NoError(err) defer db.Close() keyUID := "0x1" name := "Lopsided Goodnatured Bedbug" expected := multiaccounts.Account{ Name: name, KeyUID: keyUID, CustomizationColor: mc.CustomizationColorBlue, ColorHash: nil, ColorID: 10, KDFIterations: dbsetup.ReducedKDFIterationsNumber, Timestamp: 1712856359, Images: images.SampleIdentityImageForQRCode(), } s.Require().NoError(db.SaveAccount(expected)) accounts, err := db.GetAccounts() s.Require().NoError(err) s.Require().Len(accounts, 1) s.Require().Equal(expected, accounts[0]) w := httptest.NewRecorder() f, err := filepath.Abs("../_assets/tests/UbuntuMono-Regular.ttf") s.Require().NoError(err) p := ImageParams{ Ring: true, RingWidth: 1, KeyUID: keyUID, InitialsLength: 2, BgColor: color.Transparent, Color: color.Transparent, FontFile: f, BgSize: 1, FontSize: 1, UppercaseRatio: 1.0, ImageName: images.LargeDimName, } handleAccountImagesImpl(db, s.logger, w, p) s.validateResponse(w) // pass a public key to generate ring k, err := crypto.GenerateKey() s.Require().NoError(err) p.PublicKey = common.PubkeyToHex(&k.PublicKey) w = httptest.NewRecorder() handleAccountImagesImpl(db, s.logger, w, p) s.Require().Equal(http.StatusOK, w.Code) }