Skip to content

Commit

Permalink
fix: handle play store developer notifications correctly and update o…
Browse files Browse the repository at this point in the history
…rders
  • Loading branch information
clD11 committed Feb 6, 2024
1 parent 8821bc1 commit 911bd66
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 163 deletions.
2 changes: 1 addition & 1 deletion libs/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 83 additions & 46 deletions services/skus/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
110 changes: 110 additions & 0 deletions services/skus/controllers_noint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
38 changes: 15 additions & 23 deletions services/skus/controllers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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(&notification)
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)
}
Expand Down
Loading

0 comments on commit 911bd66

Please sign in to comment.