diff --git a/libs/handlers/handlers.go b/libs/handlers/handlers.go index 7f3c05a8b..b06305faf 100644 --- a/libs/handlers/handlers.go +++ b/libs/handlers/handlers.go @@ -75,7 +75,7 @@ func WrapError(err error, msg string, passedCode int) *AppError { } // RenderContent based on the header -func RenderContent(ctx context.Context, v interface{}, w http.ResponseWriter, status int) *AppError { +func RenderContent(_ context.Context, v interface{}, w http.ResponseWriter, status int) *AppError { switch w.Header().Get("content-type") { case "application/json": var b bytes.Buffer diff --git a/services/skus/controllers.go b/services/skus/controllers.go index 8a5af61b1..0b8fcb363 100644 --- a/services/skus/controllers.go +++ b/services/skus/controllers.go @@ -953,74 +953,111 @@ func VerifyCredentialV1(service *Service) handlers.AppHandler { // WebhookRouter - handles calls from various payment method webhooks informing payments of completion func WebhookRouter(service *Service) chi.Router { r := chi.NewRouter() - r.Method("POST", "/stripe", middleware.InstrumentHandler("HandleStripeWebhook", HandleStripeWebhook(service))) - r.Method("POST", "/radom", middleware.InstrumentHandler("HandleRadomWebhook", HandleRadomWebhook(service))) - r.Method("POST", "/android", middleware.InstrumentHandler("HandleAndroidWebhook", HandleAndroidWebhook(service))) - r.Method("POST", "/ios", middleware.InstrumentHandler("HandleIOSWebhook", HandleIOSWebhook(service))) + r.Method(http.MethodPost, "/stripe", middleware.InstrumentHandler("HandleStripeWebhook", HandleStripeWebhook(service))) + r.Method(http.MethodPost, "/radom", middleware.InstrumentHandler("HandleRadomWebhook", HandleRadomWebhook(service))) + r.Method(http.MethodPost, "/android", middleware.InstrumentHandler("HandleAndroidWebhook", handleAndroidWebhook(service))) + r.Method(http.MethodPost, "/ios", middleware.InstrumentHandler("HandleIOSWebhook", HandleIOSWebhook(service))) return r } -// HandleAndroidWebhook is the handler for the Google Playstore webhooks -func HandleAndroidWebhook(service *Service) handlers.AppHandler { +const errInternalServer model.Error = "internal server error" + +func handleAndroidWebhook(service *Service) handlers.AppHandler { return func(w http.ResponseWriter, r *http.Request) *handlers.AppError { ctx := r.Context() - l := logging.Logger(ctx, "payments").With().Str("func", "HandleAndroidWebhook").Logger() + l := logging.Logger(ctx, "skus").With().Str("func", "handleAndroidWebhook").Logger() - if err := service.gcpValidator.validate(ctx, r); err != nil { - l.Error().Err(err).Msg("invalid request") - return handlers.WrapError(err, "invalid request", http.StatusUnauthorized) + b, err := io.ReadAll(io.LimitReader(r.Body, reqBodyLimit10MB)) + if err != nil { + l.Error().Err(err).Msg("error reading request body") + return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK) } - payload, err := requestutils.Read(r.Context(), r.Body) + dn, err := parseDeveloperNotification(b) if err != nil { - l.Error().Err(err).Msg("failed to read payload") - return handlers.WrapValidationError(err) + l.Error().Err(err).Msg("error parsing notification") + return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK) } - l.Info().Str("payload", string(payload)).Msg("") - - var validationErrMap = map[string]interface{}{} + if err := service.verifyDeveloperNotification(ctx, dn); err != nil { + switch { + case errors.Is(err, model.ErrOrderNotFound): + // This error can legitimately be returned when a user has not linked their mobile purchase. + // Currently, there is no way to distinguish between a missing order and a user that + // has not linked. We have no choice but to ack the message. + return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK) - var req AndroidNotification - if err := inputs.DecodeAndValidate(context.Background(), &req, payload); err != nil { - validationErrMap["request-body-decode"] = err.Error() - l.Error().Interface("validation_map", validationErrMap).Msg("validation_error") - return handlers.ValidationError("Error validating request", validationErrMap) + default: + l.Error().Err(err).Msg("error verifying notification") + return handlers.WrapError(errInternalServer, "error verifying notification", http.StatusInternalServerError) + } } - l.Info().Interface("req", req).Msg("") + return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK) + } +} - dn, err := req.Message.GetDeveloperNotification() - if err != nil { - validationErrMap["invalid-developer-notification"] = err.Error() - l.Error().Interface("validation_map", validationErrMap).Msg("validation_error") - return handlers.ValidationError("Error validating request", validationErrMap) - } +type gcpMsgWrapper struct { + Message gcpMsg `json:"message"` +} - l.Info().Interface("developer_notification", dn).Msg("") +type gcpMsg struct { + Data string `json:"data"` + MessageID string `json:"messageId"` +} - if dn == nil || dn.SubscriptionNotification.PurchaseToken == "" { - validationErrMap["invalid-developer-notification-token"] = "notification has no purchase token" - l.Error().Interface("validation_map", validationErrMap).Msg("validation_error") - return handlers.ValidationError("Error validating request", validationErrMap) - } +type developerNotification struct { + PackageName string `json:"packageName"` + SubscriptionNotification subscriptionNotification `json:"subscriptionNotification"` +} - l.Info().Msg("verify_developer_notification") +type subscriptionNotification struct { + NotificationType int `json:"notificationType"` + PurchaseToken string `json:"purchaseToken"` + SubscriptionID string `json:"subscriptionId"` +} - err = service.verifyDeveloperNotification(ctx, dn) - if err != nil { - l.Error().Err(err).Msg("failed to verify subscription notification") - switch { - case errors.Is(err, errNotFound): - return handlers.WrapError(err, "failed to verify subscription notification", http.StatusNotFound) - default: - return handlers.WrapError(err, "failed to verify subscription notification", http.StatusInternalServerError) - } - } +const ( + errPackageNameEmpty model.Error = "package name is empty" + errSubscriptionIDEmpty model.Error = "subscription id is empty" + errPurchaseTokenEmpty model.Error = "purchase token is empty" + errNotificationTypeInvalid model.Error = "invalid notification type" +) - return handlers.RenderContent(ctx, "event received", w, http.StatusOK) +func parseDeveloperNotification(raw []byte) (developerNotification, error) { + var m gcpMsgWrapper + if err := json.Unmarshal(raw, &m); err != nil { + return developerNotification{}, fmt.Errorf("error unmarshaling msg wrapper: %w", err) + } + + b, err := base64.StdEncoding.DecodeString(m.Message.Data) + if err != nil { + return developerNotification{}, fmt.Errorf("error decoding msg data: %w", err) + } + + var dn developerNotification + if err := json.Unmarshal(b, &dn); err != nil { + return developerNotification{}, fmt.Errorf("error unmarshaling developer notification: %w", err) + } + + if dn.PackageName == "" { + return developerNotification{}, errPackageNameEmpty + } + + if dn.SubscriptionNotification.SubscriptionID == "" { + return developerNotification{}, errSubscriptionIDEmpty } + + if dn.SubscriptionNotification.PurchaseToken == "" { + return developerNotification{}, errPurchaseTokenEmpty + } + + if dn.SubscriptionNotification.NotificationType == 0 { + return developerNotification{}, errNotificationTypeInvalid + } + + return dn, nil } const ( diff --git a/services/skus/controllers_noint_test.go b/services/skus/controllers_noint_test.go index ba42ec4e6..3d5d18110 100644 --- a/services/skus/controllers_noint_test.go +++ b/services/skus/controllers_noint_test.go @@ -235,3 +235,113 @@ func TestHandleReceiptErr(t *testing.T) { }) } } + +func TestParseNotification(t *testing.T) { + type tcGiven struct { + raw []byte + } + + type exp struct { + dn developerNotification + mustErr must.ErrorAssertionFunc + } + + type testCase struct { + name string + given tcGiven + exp exp + } + + tests := []testCase{ + { + name: "error_msg_wrapper", + exp: exp{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorContains(t, err, "error unmarshaling msg wrapper: ") + }, + }, + }, + { + name: "error_msg_data", + given: tcGiven{raw: []byte(`{"message":{"data":"not-base64"}}`)}, + exp: exp{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorContains(t, err, "error decoding msg data: ") + }, + }, + }, + { + name: "error_developer_notification", + given: tcGiven{raw: []byte(`{"message":{"data":"dGVzdA=="}}`)}, + exp: exp{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorContains(t, err, "error unmarshaling developer notification: ") + }, + }, + }, + { + name: "error_package_name_empty", + given: tcGiven{raw: []byte(`{"message":{"data":"eyJwYWNrYWdlTmFtZSI6IiIsInN1YnNjcmlwdGlvbk5vdGlmaWNhdGlvbiI6eyJzdWJzY3JpcHRpb25JZCI6IiIsInB1cmNoYXNlVG9rZW4iOiIiLCJub3RpZmljYXRpb25UeXBlIjogMH19"}}`)}, + exp: exp{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, errPackageNameEmpty) + }, + }, + }, + { + name: "error_subscription_id_empty", + given: tcGiven{raw: []byte(`{"message":{"data":"eyJwYWNrYWdlTmFtZSI6InBhY2thZ2UubmFtZS5jb20iLCJzdWJzY3JpcHRpb25Ob3RpZmljYXRpb24iOnsic3Vic2NyaXB0aW9uSWQiOiIiLCJwdXJjaGFzZVRva2VuIjoicGFja2FnZS10b2tlbiIsIm5vdGlmaWNhdGlvblR5cGUiOiAxfX0="}}`)}, + exp: exp{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, errSubscriptionIDEmpty) + }, + }, + }, + { + name: "error_purchase_token_empty", + given: tcGiven{raw: []byte(`{"message":{"data":"eyJwYWNrYWdlTmFtZSI6InBhY2thZ2UubmFtZS5jb20iLCJzdWJzY3JpcHRpb25Ob3RpZmljYXRpb24iOnsic3Vic2NyaXB0aW9uSWQiOiJzdWItaWQiLCJwdXJjaGFzZVRva2VuIjoiIiwibm90aWZpY2F0aW9uVHlwZSI6IDF9fQ=="}}`)}, + exp: exp{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, errPurchaseTokenEmpty) + }, + }, + }, + { + name: "error_invalid_notification_type", + given: tcGiven{raw: []byte(`{"message":{"data":"eyJwYWNrYWdlTmFtZSI6InBhY2thZ2UubmFtZS5jb20iLCJzdWJzY3JpcHRpb25Ob3RpZmljYXRpb24iOnsic3Vic2NyaXB0aW9uSWQiOiJzdWItaWQiLCJwdXJjaGFzZVRva2VuIjoicHVyY2hhc2UtdG9rZW4iLCJub3RpZmljYXRpb25UeXBlIjogMH19"}}`)}, + exp: exp{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, errNotificationTypeInvalid) + }, + }, + }, + { + name: "success", + given: tcGiven{raw: []byte(`{"message":{"data":"eyJ2ZXJzaW9uIjoidmVyc2lvbiIsInBhY2thZ2VOYW1lIjoicGFja2FnZS1uYW1lIiwic3Vic2NyaXB0aW9uTm90aWZpY2F0aW9uIjp7InZlcnNpb24iOiJ2ZXJzaW9uIiwibm90aWZpY2F0aW9uVHlwZSI6MSwicHVyY2hhc2VUb2tlbiI6InB1cmNoYXNlLXRva2VuIiwic3Vic2NyaXB0aW9uSWQiOiJzdWJzY3JpcHRpb24taWQifX0="}}`)}, + exp: exp{ + dn: developerNotification{ + PackageName: "package-name", + SubscriptionNotification: subscriptionNotification{ + NotificationType: 1, + PurchaseToken: "purchase-token", + SubscriptionID: "subscription-id", + }, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual, err := parseDeveloperNotification(tc.given.raw) + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.dn, actual) + }) + } +} diff --git a/services/skus/controllers_test.go b/services/skus/controllers_test.go index e20575f52..ae3940b3a 100644 --- a/services/skus/controllers_test.go +++ b/services/skus/controllers_test.go @@ -364,12 +364,8 @@ func (suite *ControllersTestSuite) TestIOSWebhookCertFail() { func (suite *ControllersTestSuite) TestAndroidWebhook() { order, _ := suite.setupCreateOrder(UserWalletVoteTestSkuToken, UserWalletVoteToken, 40) - suite.Assert().NotNil(order) - - // Check the order - suite.Assert().Equal("10", order.TotalPrice.String()) + suite.Require().NotNil(order) - // add the external id to metadata as if an initial receipt was submitted err := suite.storage.AppendOrderMetadata(context.Background(), &order.ID, "externalID", "my external id") suite.Require().NoError(err) @@ -381,43 +377,39 @@ func (suite *ControllersTestSuite) TestAndroidWebhook() { suite.service.gcpValidator = &mockGcpRequestValidator{} - handler := HandleAndroidWebhook(suite.service) - - // notification message - devNotify := DeveloperNotification{ + dn := developerNotification{ PackageName: "package name", - SubscriptionNotification: SubscriptionNotification{ + SubscriptionNotification: subscriptionNotification{ NotificationType: androidSubscriptionCanceled, PurchaseToken: "my external id", SubscriptionID: "subscription id", }, } - buf, err := json.Marshal(&devNotify) + buf, err := json.Marshal(&dn) suite.Require().NoError(err) - // wrapper notification message - notification := &AndroidNotification{ - Message: AndroidNotificationMessage{ - Data: base64.StdEncoding.EncodeToString(buf), // dev notification is b64 encoded + msg := gcpMsgWrapper{ + Message: gcpMsg{ + Data: base64.StdEncoding.EncodeToString(buf), }, - Subscription: "subscription", } - body, err := json.Marshal(¬ification) + body, err := json.Marshal(&msg) suite.Require().NoError(err) - req, err := http.NewRequest("POST", "/v1/android", bytes.NewBuffer(body)) + r, err := http.NewRequest(http.MethodPost, "/android", bytes.NewBuffer(body)) suite.Require().NoError(err) - req = req.WithContext(context.WithValue(req.Context(), appctx.EnvironmentCTXKey, "development")) + router := WebhookRouter(suite.service) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + rw := httptest.NewRecorder() - suite.Require().Equal(http.StatusOK, rr.Code) + server := &http.Server{Addr: ":8080", Handler: router} + server.Handler.ServeHTTP(rw, r) + + suite.Require().Equal(http.StatusOK, rw.Code) - // get order and check the state changed to canceled updatedOrder, err := suite.service.Datastore.GetOrder(order.ID) suite.Assert().Equal("canceled", updatedOrder.Status) } diff --git a/services/skus/input.go b/services/skus/input.go index 0b593fe7e..32f9719fc 100644 --- a/services/skus/input.go +++ b/services/skus/input.go @@ -143,96 +143,6 @@ const ( androidSubscriptionExpired ) -// SubscriptionNotification - an android subscription notification -type SubscriptionNotification struct { - Version string `json:"version"` - NotificationType int `json:"notificationType"` - PurchaseToken string `json:"purchaseToken"` - SubscriptionID string `json:"subscriptionId"` -} - -// DeveloperNotification - developer notification details from AndroidNotificationMessage.Data -type DeveloperNotification struct { - Version string `json:"version"` - PackageName string `json:"packageName"` - SubscriptionNotification SubscriptionNotification `json:"subscriptionNotification"` -} - -// AndroidNotificationMessageAttrs - attributes of a notification message -type AndroidNotificationMessageAttrs map[string]string - -// AndroidNotificationMessage - wrapping structure of an android notification -type AndroidNotificationMessage struct { - Attributes AndroidNotificationMessageAttrs `json:"attributes" valid:"-"` - Data string `json:"data" valid:"base64"` - MessageID string `json:"messageId" valid:"-"` -} - -// Decode - implement Decodable interface -func (anm *AndroidNotificationMessage) Decode(ctx context.Context, data []byte) error { - logger := logging.Logger(ctx, "AndroidNotificationMessage.Decode") - logger.Debug().Msg("starting AndroidNotificationMessage.Decode") - - if err := json.Unmarshal(data, anm); err != nil { - return fmt.Errorf("failed to json decode android notification message: %w", err) - } - return nil -} - -// Validate - implement Validatable interface -func (anm *AndroidNotificationMessage) Validate(ctx context.Context) error { - logger := logging.Logger(ctx, "AndroidNotificationMessage.Validate") - if _, err := govalidator.ValidateStruct(anm); err != nil { - logger.Error().Err(err).Msg("failed to validate request") - return fmt.Errorf("failed to validate android notification message: %w", err) - } - return nil -} - -// GetDeveloperNotification - Extract the developer notification from the android notification message -func (anm *AndroidNotificationMessage) GetDeveloperNotification() (*DeveloperNotification, error) { - var devNotification = new(DeveloperNotification) - buf := make([]byte, base64.StdEncoding.DecodedLen(len([]byte(anm.Data)))) - - n, err := base64.StdEncoding.Decode(buf, []byte(anm.Data)) - if err != nil { - return nil, fmt.Errorf("failed to decode input base64: %w", err) - } - - if err := json.Unmarshal(buf[:n], devNotification); err != nil { - return nil, fmt.Errorf("failed to decode input json: %w", err) - } - - return devNotification, nil -} - -// AndroidNotification - wrapping structure of an android notification -type AndroidNotification struct { - Message AndroidNotificationMessage `json:"message" valid:"-"` - Subscription string `json:"subscription" valid:"-"` -} - -// Decode - implement Decodable interface -func (an *AndroidNotification) Decode(ctx context.Context, data []byte) error { - logger := logging.Logger(ctx, "AndroidNotification.Decode") - logger.Debug().Msg("starting AndroidNotification.Decode") - - if err := json.Unmarshal(data, an); err != nil { - return fmt.Errorf("failed to json decode android notification: %w", err) - } - return nil -} - -// Validate - implement Validable interface -func (an *AndroidNotification) Validate(ctx context.Context) error { - logger := logging.Logger(ctx, "AndroidNotification.Validate") - if _, err := govalidator.ValidateStruct(an); err != nil { - logger.Error().Err(err).Msg("failed to validate request") - return fmt.Errorf("failed to validate android notification: %w", err) - } - return nil -} - // IOSNotification - wrapping structure of an android notification type IOSNotification struct { payload []byte `json:"-" valid:"-"` diff --git a/services/skus/service.go b/services/skus/service.go index 0cc7a0a63..dd44d696a 100644 --- a/services/skus/service.go +++ b/services/skus/service.go @@ -1618,8 +1618,9 @@ func (s *Service) verifyIOSNotification(ctx context.Context, txInfo *appstore.JW return nil } +// TODO(cld11): rename method to reflect behaviour and refactor. // verifyDeveloperNotification - verify the developer notification from playstore -func (s *Service) verifyDeveloperNotification(ctx context.Context, dn *DeveloperNotification) error { +func (s *Service) verifyDeveloperNotification(ctx context.Context, dn developerNotification) error { // lookup the order based on the token as externalID o, err := s.Datastore.GetOrderByExternalID(dn.SubscriptionNotification.PurchaseToken) if err != nil { @@ -1627,7 +1628,7 @@ func (s *Service) verifyDeveloperNotification(ctx context.Context, dn *Developer } if o == nil { - return fmt.Errorf("failed to get order from db: %w", errNotFound) + return model.ErrOrderNotFound } // have order, now validate the receipt from the notification @@ -1658,7 +1659,7 @@ func (s *Service) verifyDeveloperNotification(ctx context.Context, dn *Developer androidSubscriptionOnHold, androidSubscriptionCanceled, androidSubscriptionUnknown: - if err = s.CancelOrder(o.ID); err != nil { + if err = s.Datastore.UpdateOrder(o.ID, OrderStatusCanceled); err != nil { return fmt.Errorf("failed to cancel subscription in skus: %w", err) } default: